Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f138a645f8 | ||
|
|
ac66945c93 | ||
|
|
763a17f8dc | ||
|
|
8e0bcbe580 | ||
|
|
25577b4b83 | ||
|
|
2cd3c2d965 | ||
|
|
b66703082f |
No files matched your search
@@ -8,10 +8,10 @@ COOKIE_SECURE=false
|
||||
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
|
||||
|
||||
# Bootstrap Admin Credentials
|
||||
# Default username: admin
|
||||
# Default password: admin
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
|
||||
# REQUIRED for a fresh deployment.
|
||||
# Use a strong unique password.
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Cron & Cleaner Intervals (in minutes)
|
||||
LINK_CHECK_INTERVAL_MINS=60
|
||||
|
||||
@@ -6,3 +6,4 @@ data/
|
||||
.env
|
||||
|
||||
.idea/
|
||||
bzod.env
|
||||
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "bzod"
|
||||
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/thakares/nx9-url-shortener"
|
||||
|
||||
@@ -1,73 +1,95 @@
|
||||
# ==========================================
|
||||
# Stage 1: Builder (with optimized caching)
|
||||
# Stage 1: Builder
|
||||
# ==========================================
|
||||
FROM rust:1.89-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only Cargo files first (best caching)
|
||||
# Dependency metadata first for Docker layer caching
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Create dummy source for dependency caching
|
||||
# Dummy build to cache Rust dependencies
|
||||
RUN mkdir -p src && \
|
||||
echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
|
||||
cargo build --release && \
|
||||
rm -rf src target/release/deps/bzod*
|
||||
printf 'fn main() {}\n' > src/main.rs && \
|
||||
cargo build --release --locked && \
|
||||
rm -rf src
|
||||
|
||||
# Copy real source code + assets
|
||||
# Actual application source and runtime assets
|
||||
COPY src ./src
|
||||
COPY templates ./templates
|
||||
COPY www ./www
|
||||
|
||||
# Build the real application
|
||||
RUN cargo build --release
|
||||
# Reproducible production build
|
||||
RUN cargo build --release --locked
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Stage 2: Runtime (slim)
|
||||
# Stage 2: Runtime
|
||||
# ==========================================
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
openssl \
|
||||
# Runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy binary from builder
|
||||
# Create unprivileged runtime user
|
||||
RUN groupadd --gid 1000 bzod && \
|
||||
useradd --uid 1000 --gid 1000 \
|
||||
--create-home \
|
||||
--shell /usr/sbin/nologin \
|
||||
bzod
|
||||
|
||||
# Application binary
|
||||
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Copy assets
|
||||
COPY --from=builder /app/templates ./templates
|
||||
COPY --from=builder /app/www ./www
|
||||
# Application-owned immutable assets
|
||||
COPY --from=builder /app/templates /app/templates
|
||||
COPY --from=builder /app/www /app/www
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1000 bzod && \
|
||||
useradd -u 1000 -g bzod -m -s /bin/bash bzod
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R bzod:bzod /app
|
||||
|
||||
USER bzod
|
||||
# Persistent runtime directories.
|
||||
# /app/images is intentionally external/persistent in Compose.
|
||||
RUN mkdir -p \
|
||||
/app/data \
|
||||
/app/config \
|
||||
/app/images && \
|
||||
chown -R bzod:bzod \
|
||||
/app/data \
|
||||
/app/config \
|
||||
/app/images \
|
||||
/app/templates \
|
||||
/app/www \
|
||||
/usr/local/bin/bzod \
|
||||
/usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Runtime configuration
|
||||
ENV DATA_DIR=/app/data \
|
||||
CONFIG_DIR=/app/config \
|
||||
IMAGES_DIR=/app/images \
|
||||
PORT=8654 \
|
||||
HOST=0.0.0.0 \
|
||||
COOKIE_SECURE=true
|
||||
|
||||
EXPOSE 8654
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT}/status || exit 1
|
||||
HEALTHCHECK \
|
||||
--interval=30s \
|
||||
--timeout=5s \
|
||||
--start-period=10s \
|
||||
--retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${PORT}/status" || exit 1
|
||||
|
||||
ENTRYPOINT ["bzod"]
|
||||
CMD ["serve"]
|
||||
USER bzod
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["serve"]
|
||||
@@ -6,7 +6,7 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[](https://github.com/thakares/bzod)
|
||||
[](https://codeberg.org/thakares/bzod)
|
||||
@@ -35,6 +35,8 @@ Core capabilities include:
|
||||
- Multi-User Platform
|
||||
- REST API
|
||||
- Role-Based Access Control (RBAC)
|
||||
- Responsive UI (Desktop, Tablet, Mobile)
|
||||
- Dark/Light Theme Support
|
||||
- Global Namespace Registry
|
||||
- Registry Validation
|
||||
- Transaction-safe Registry Repair
|
||||
@@ -44,6 +46,7 @@ Core capabilities include:
|
||||
- User Backup & Restore
|
||||
- Disaster Recovery
|
||||
- Upgrade Validation
|
||||
- Build Metadata (Version & Git Commit)
|
||||
- Single Binary Deployment
|
||||
|
||||
The result is a platform that is easy to deploy, lightweight to operate, and entirely controlled by its owner.
|
||||
@@ -90,7 +93,7 @@ No recurring subscription fees.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Efficiency (v0.6.0)
|
||||
## Runtime Efficiency (v0.7.0)
|
||||
|
||||
| Metric | Value |
|
||||
|---------|------:|
|
||||
@@ -1135,16 +1138,37 @@ Highlights include:
|
||||
- Upgrade Validation
|
||||
- Backup Manifest
|
||||
- Transaction-safe Maintenance
|
||||
- Modular Admin Architecture (v0.6.0)
|
||||
- Redirect Handler Hardening (v0.6.0)
|
||||
- Modular Admin Architecture (v0.5.3)
|
||||
- Redirect Handler Hardening (v0.5.3)
|
||||
|
||||
---
|
||||
|
||||
### v0.6
|
||||
|
||||
- Legacy Backup Restore Compatibility
|
||||
- CLI Version Reporting (`--version` / `-V`)
|
||||
- Deploy Script Modernization
|
||||
|
||||
---
|
||||
|
||||
### v0.7
|
||||
|
||||
Responsive UI and theme refactor.
|
||||
|
||||
Highlights include:
|
||||
|
||||
- Responsive Admin & User Registry Panels
|
||||
- Desktop, Laptop, Tablet, and Mobile Layouts
|
||||
- Dark/Light Theme Support with Persistence
|
||||
- Table-to-Card Responsive Behavior
|
||||
- Build Metadata (Version & Git Commit)
|
||||
- Root Landing Page Runtime/Embedded Fallback
|
||||
|
||||
---
|
||||
|
||||
# Future Roadmap
|
||||
|
||||
## v0.6
|
||||
|
||||
Planned features include:
|
||||
Planned features and improvements:
|
||||
|
||||
- Webhooks
|
||||
- OpenAPI Documentation
|
||||
@@ -1155,13 +1179,6 @@ Planned features include:
|
||||
- Bulk Export
|
||||
- Custom Domains
|
||||
- Better QR Styling
|
||||
|
||||
---
|
||||
|
||||
## v0.7
|
||||
|
||||
Planned improvements:
|
||||
|
||||
- Email Notifications
|
||||
- Team Workspaces
|
||||
- Shared Projects
|
||||
@@ -1320,7 +1337,7 @@ Community feedback helps guide future development.
|
||||
|
||||
**Current Version**
|
||||
|
||||
**v0.5.3**
|
||||
**v0.7.0**
|
||||
|
||||
Production Ready
|
||||
|
||||
@@ -1336,12 +1353,15 @@ Production Ready
|
||||
- ✅ Analytics
|
||||
- ✅ REST API
|
||||
- ✅ Role-Based Access Control
|
||||
- ✅ Responsive UI (Desktop, Tablet, Mobile)
|
||||
- ✅ Dark/Light Theme Support
|
||||
- ✅ Registry Validator
|
||||
- ✅ Registry Repair Framework
|
||||
- ✅ Health Diagnostics
|
||||
- ✅ Backup & Restore
|
||||
- ✅ Disaster Recovery
|
||||
- ✅ Upgrade Validation
|
||||
- ✅ Build Metadata (Version & Git Commit)
|
||||
- ✅ Extensive Automated Test Suite
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
|
||||
if let Ok(output) = Command::new("git")
|
||||
.args(["rev-parse", "--short=12", "HEAD"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !commit.is_empty() {
|
||||
println!("cargo:rustc-env=BZOD_GIT_COMMIT={commit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,251 +1,475 @@
|
||||
#!/usr/bin/env bash
|
||||
# BZOD Production Deployment Script
|
||||
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
#
|
||||
# BZOD Production Docker Deployment
|
||||
#
|
||||
# Privacy-First URL Shortener & Landing Page Platform
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
#
|
||||
# Or:
|
||||
# sudo bash deploy.sh
|
||||
#
|
||||
# Environment overrides:
|
||||
# BZOD_VERSION=0.7.1
|
||||
# BZOD_IMAGE=nx9-url-shortener
|
||||
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
|
||||
# BZOD_PORT=8654
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BZOD_VERSION="0.6.0"
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
|
||||
SERVICE_USER="bzod"
|
||||
INSTALL_PATH="/usr/local/bin/bzod"
|
||||
CONFIG_DIR="/etc/bzod"
|
||||
DATA_DIR="/var/lib/bzod/data"
|
||||
ENV_FILE="${CONFIG_DIR}/bzod.env"
|
||||
SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
|
||||
BZOD_VERSION="${BZOD_VERSION:-0.7.1}"
|
||||
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
|
||||
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
|
||||
BZOD_PORT="${BZOD_PORT:-8654}"
|
||||
BASE_URL="${BASE_URL:-https://bzo.in}"
|
||||
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-bzod}"
|
||||
|
||||
DATA_DIR="${BZOD_ROOT}/data"
|
||||
CONFIG_DIR="${BZOD_ROOT}/config"
|
||||
IMAGES_DIR="${BZOD_ROOT}/images"
|
||||
COMPOSE_DIR="${BZOD_ROOT}/compose"
|
||||
|
||||
COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yml"
|
||||
ENV_FILE="${COMPOSE_DIR}/bzod.env"
|
||||
BACKUP_ROOT="${BZOD_ROOT}/backups"
|
||||
|
||||
IMAGE="${BZOD_IMAGE}:${BZOD_VERSION}"
|
||||
|
||||
# ============================================================
|
||||
# Output
|
||||
# ============================================================
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Temporary file cleanup
|
||||
TMP_BINARY=""
|
||||
cleanup() {
|
||||
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
|
||||
info() {
|
||||
echo -e "${BLUE}$*${NC}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}"
|
||||
echo -e "Production deployment started...\n"
|
||||
success() {
|
||||
echo -e "${GREEN}$*${NC}"
|
||||
}
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: This script must be run as root (use sudo).${NC}"
|
||||
warning() {
|
||||
echo -e "${YELLOW}$*${NC}"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}$*${NC}" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
error "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Root check
|
||||
# ============================================================
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
die "This script must be run as root. Use: sudo bash deploy.sh"
|
||||
fi
|
||||
|
||||
# 1. Install Base Dependencies
|
||||
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
|
||||
apt-get update -qq
|
||||
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
|
||||
echo
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo -e "${BLUE} BZOD — Production Docker Deployment${NC}"
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo
|
||||
echo "Version: ${BZOD_VERSION}"
|
||||
echo "Image: ${IMAGE}"
|
||||
echo "Application: ${BZOD_ROOT}"
|
||||
echo "Data: ${DATA_DIR}"
|
||||
echo "Config: ${CONFIG_DIR}"
|
||||
echo "Images: ${IMAGES_DIR}"
|
||||
echo "Port: ${BZOD_PORT}"
|
||||
echo
|
||||
|
||||
# 2. Install Binary (safe atomic download)
|
||||
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
|
||||
# ============================================================
|
||||
# 1. Install Docker
|
||||
# ============================================================
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case $ARCH in
|
||||
x86_64) BINARY_NAME="bzod-x86_64-unknown-linux-gnu" ;;
|
||||
aarch64|arm64) BINARY_NAME="bzod-aarch64-unknown-linux-gnu" ;;
|
||||
armv7l) BINARY_NAME="bzod-armv7-unknown-linux-gnueabihf" ;;
|
||||
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
|
||||
esac
|
||||
info "[1/8] Checking Docker..."
|
||||
|
||||
REPO="thakares/nx9-url-shortener"
|
||||
RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${BINARY_NAME}"
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
info "Docker is not installed. Installing Docker..."
|
||||
|
||||
TMP_BINARY=$(mktemp)
|
||||
apt-get update -qq
|
||||
apt-get install -y \
|
||||
ca-certificates \
|
||||
curl
|
||||
|
||||
echo "Trying GitHub Releases..."
|
||||
if curl --retry 5 --retry-delay 2 --retry-connrefused \
|
||||
-L -f -o "${TMP_BINARY}" "${RELEASE_URL}" 2>/dev/null; then
|
||||
echo -e "${GREEN}✓ Downloaded from GitHub Releases${NC}"
|
||||
else
|
||||
echo -e "${BLUE}GitHub Releases not available. Trying GHCR...${NC}"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
TMP_GHCR=$(mktemp)
|
||||
docker pull ghcr.io/${REPO}:latest >/dev/null 2>&1 || true
|
||||
if docker run --rm --entrypoint cat ghcr.io/${REPO}:latest /usr/local/bin/bzod > "${TMP_GHCR}" 2>/dev/null && [ -s "${TMP_GHCR}" ]; then
|
||||
mv "${TMP_GHCR}" "${TMP_BINARY}"
|
||||
echo -e "${GREEN}✓ Extracted from GHCR${NC}"
|
||||
fi
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
|
||||
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||
curl -fsSL \
|
||||
https://download.docker.com/linux/debian/gpg \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
|
||||
if [ ! -s "${TMP_BINARY}" ]; then
|
||||
echo -e "${BLUE}Falling back to local build...${NC}"
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo -e "${RED}Neither pre-built binary nor cargo available.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
apt-get install -y pkg-config build-essential
|
||||
cargo build --release
|
||||
cp target/release/bzod "${TMP_BINARY}"
|
||||
echo -e "${GREEN}✓ Built from source${NC}"
|
||||
fi
|
||||
. /etc/os-release
|
||||
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
|
||||
https://download.docker.com/linux/debian \
|
||||
${VERSION_CODENAME} stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
|
||||
apt-get update -qq
|
||||
|
||||
apt-get install -y \
|
||||
docker-ce \
|
||||
docker-ce-cli \
|
||||
containerd.io \
|
||||
docker-buildx-plugin \
|
||||
docker-compose-plugin
|
||||
fi
|
||||
|
||||
# Atomic replace with backup
|
||||
if [ -f "${INSTALL_PATH}" ]; then
|
||||
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
systemctl enable --now docker
|
||||
fi
|
||||
|
||||
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
|
||||
|
||||
# Verify
|
||||
if [ ! -x "${INSTALL_PATH}" ]; then
|
||||
echo -e "${RED}Binary installation failed${NC}"
|
||||
exit 1
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
die "Docker Compose plugin is unavailable."
|
||||
fi
|
||||
|
||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || {
|
||||
echo -e "${RED}Binary verification failed${NC}"
|
||||
exit 1
|
||||
}
|
||||
success "✓ Docker and Docker Compose available"
|
||||
|
||||
# Verify -V also works
|
||||
"${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || {
|
||||
echo -e "${RED}Binary -V verification failed${NC}"
|
||||
exit 1
|
||||
}
|
||||
# ============================================================
|
||||
# 2. Create persistent directories
|
||||
# ============================================================
|
||||
|
||||
# Show installed version and verify it matches requested version
|
||||
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
EXPECTED_VERSION="bzod ${BZOD_VERSION}"
|
||||
if [ "${VERSION}" != "${EXPECTED_VERSION}" ]; then
|
||||
echo -e "${RED}Version mismatch: expected '${EXPECTED_VERSION}', got '${VERSION}'${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
|
||||
info "[2/8] Creating persistent application directories..."
|
||||
|
||||
# 3. Create System User
|
||||
echo -e "\n${BLUE}[3/8] Creating system user '${SERVICE_USER}'...${NC}"
|
||||
if ! id -u "${SERVICE_USER}" &>/dev/null; then
|
||||
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
|
||||
fi
|
||||
mkdir -p \
|
||||
"${DATA_DIR}" \
|
||||
"${CONFIG_DIR}" \
|
||||
"${IMAGES_DIR}" \
|
||||
"${COMPOSE_DIR}" \
|
||||
"${BACKUP_ROOT}"
|
||||
|
||||
# 4. Setup Directories
|
||||
echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
|
||||
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
|
||||
chmod 700 "${CONFIG_DIR}"
|
||||
chmod 755 "${IMAGES_DIR}"
|
||||
|
||||
success "✓ Persistent directories ready"
|
||||
|
||||
# ============================================================
|
||||
# 3. Configuration
|
||||
# ============================================================
|
||||
|
||||
info "[3/8] Preparing configuration..."
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
|
||||
cat > "${ENV_FILE}" <<EOF
|
||||
BZOD_VERSION=${BZOD_VERSION}
|
||||
BZOD_IMAGE=${BZOD_IMAGE}
|
||||
|
||||
# 5. Configuration (preserve on upgrades)
|
||||
echo -e "\n${BLUE}[5/8] Configuration...${NC}"
|
||||
if [ ! -f "${ENV_FILE}" ]; then
|
||||
echo -e "${BLUE}Generating new secure configuration...${NC}"
|
||||
cat <<EOF > "${ENV_FILE}"
|
||||
HOST=0.0.0.0
|
||||
PORT=8654
|
||||
DATA_DIR=${DATA_DIR}
|
||||
|
||||
DATA_DIR=/app/data
|
||||
CONFIG_DIR=/app/config
|
||||
IMAGES_DIR=/app/images
|
||||
|
||||
COOKIE_SECURE=true
|
||||
RUST_LOG=info
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
BASE_URL=${BASE_URL}
|
||||
EOF
|
||||
|
||||
chmod 600 "${ENV_FILE}"
|
||||
chown root:"${SERVICE_USER}" "${ENV_FILE}"
|
||||
|
||||
success "✓ New Docker configuration created"
|
||||
|
||||
else
|
||||
echo -e "${GREEN}Existing configuration preserved${NC}"
|
||||
|
||||
warning "Existing Docker configuration preserved"
|
||||
|
||||
# Update image/version while preserving all other settings.
|
||||
sed -i \
|
||||
-E "s#^BZOD_VERSION=.*#BZOD_VERSION=${BZOD_VERSION}#" \
|
||||
"${ENV_FILE}" || true
|
||||
|
||||
sed -i \
|
||||
-E "s#^BZOD_IMAGE=.*#BZOD_IMAGE=${BZOD_IMAGE}#" \
|
||||
"${ENV_FILE}" || true
|
||||
|
||||
if grep -q '^BASE_URL=' "${ENV_FILE}"; then
|
||||
sed -i \
|
||||
-E "s#^BASE_URL=.*#BASE_URL=${BASE_URL}#" \
|
||||
"${ENV_FILE}" || true
|
||||
else
|
||||
echo "BASE_URL=${BASE_URL}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
# 6. Systemd Service
|
||||
echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}"
|
||||
cat <<EOF > "${SYSTEMD_UNIT}"
|
||||
[Unit]
|
||||
Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# ============================================================
|
||||
# 4. Create Compose definition
|
||||
# ============================================================
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=/var/lib/bzod
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${INSTALL_PATH} serve
|
||||
info "[4/8] Writing Docker Compose configuration..."
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
cat > "${COMPOSE_FILE}" <<'EOF'
|
||||
services:
|
||||
|
||||
# Security Hardening
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectHostname=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
NoNewPrivileges=yes
|
||||
ReadWritePaths=/var/lib/bzod
|
||||
bzod:
|
||||
image: ${BZOD_IMAGE}:${BZOD_VERSION}
|
||||
container_name: bzod
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${PORT:-8654}:8654"
|
||||
|
||||
environment:
|
||||
HOST: "${HOST:-0.0.0.0}"
|
||||
PORT: "${PORT:-8654}"
|
||||
|
||||
DATA_DIR: "/app/data"
|
||||
CONFIG_DIR: "/app/config"
|
||||
IMAGES_DIR: "/app/images"
|
||||
|
||||
COOKIE_SECURE: "${COOKIE_SECURE:-true}"
|
||||
RUST_LOG: "${RUST_LOG:-info}"
|
||||
BASE_URL: "${BASE_URL:-https://bzo.in}"
|
||||
|
||||
volumes:
|
||||
|
||||
# Persistent application databases.
|
||||
- ${BZOD_ROOT}/data:/app/data
|
||||
|
||||
# Persistent application configuration.
|
||||
- ${BZOD_ROOT}/config:/app/config
|
||||
|
||||
# User-uploaded / application images.
|
||||
#
|
||||
# IMPORTANT:
|
||||
# /app/images is required by the image router.
|
||||
- ${BZOD_ROOT}/images:/app/images
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"curl",
|
||||
"-fsS",
|
||||
"http://127.0.0.1:8654/status"
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
EOF
|
||||
|
||||
chmod 644 "${SYSTEMD_UNIT}"
|
||||
systemctl daemon-reload
|
||||
|
||||
# 7. Initialize & Start
|
||||
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
|
||||
|
||||
# Database creation and migration is handled automatically by 'bzod serve'
|
||||
if [ -f "${DATA_DIR}/admin/admin.db" ] || [ -f "${DATA_DIR}/admin.db" ]; then
|
||||
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
|
||||
|
||||
# Pre-upgrade: stop service and backup databases
|
||||
if systemctl is-active --quiet bzod 2>/dev/null; then
|
||||
echo -e "${BLUE} Stopping BZOD for safe database backup...${NC}"
|
||||
systemctl stop bzod
|
||||
fi
|
||||
|
||||
BACKUP_DIR="/var/lib/bzod/pre-upgrade-backup-v${BZOD_VERSION}"
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data" 2>/dev/null || true
|
||||
cp "${ENV_FILE}" "${BACKUP_DIR}/bzod.env" 2>/dev/null || true
|
||||
echo -e "${GREEN} ✓ Pre-upgrade backup created at ${BACKUP_DIR}${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✓ Fresh installation (databases will be created on first start)${NC}"
|
||||
# Append BZOD_ROOT because compose needs it.
|
||||
if ! grep -q '^BZOD_ROOT=' "${ENV_FILE}"; then
|
||||
echo "BZOD_ROOT=${BZOD_ROOT}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
systemctl enable --now bzod
|
||||
# Port variable expected by compose.
|
||||
if ! grep -q '^PORT=' "${ENV_FILE}"; then
|
||||
echo "PORT=${BZOD_PORT}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
# 8. Validation + Rollback
|
||||
sleep 3
|
||||
success "✓ Docker Compose configuration written"
|
||||
|
||||
# ============================================================
|
||||
# 5. Backup existing installation
|
||||
# ============================================================
|
||||
|
||||
info "[5/8] Creating pre-upgrade backup..."
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d-%H%M%S')"
|
||||
BACKUP_DIR="${BACKUP_ROOT}/pre-upgrade-${TIMESTAMP}-v${BZOD_VERSION}"
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
if [[ -d "${DATA_DIR}" ]]; then
|
||||
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data"
|
||||
fi
|
||||
|
||||
if [[ -d "${CONFIG_DIR}" ]]; then
|
||||
cp -a "${CONFIG_DIR}" "${BACKUP_DIR}/config"
|
||||
fi
|
||||
|
||||
if [[ -d "${IMAGES_DIR}" ]]; then
|
||||
cp -a "${IMAGES_DIR}" "${BACKUP_DIR}/images"
|
||||
fi
|
||||
|
||||
cp -a "${COMPOSE_FILE}" "${BACKUP_DIR}/docker-compose.yml"
|
||||
cp -a "${ENV_FILE}" "${BACKUP_DIR}/bzod.env"
|
||||
|
||||
success "✓ Backup created:"
|
||||
echo " ${BACKUP_DIR}"
|
||||
|
||||
# ============================================================
|
||||
# 6. Pull new image
|
||||
# ============================================================
|
||||
|
||||
info "[6/8] Building BZOD ${BZOD_VERSION} image..."
|
||||
|
||||
# Build locally from the current deployment tree. The package/repository is
|
||||
# nx9-url-shortener; the application binary and container remain named bzod.
|
||||
if ! docker build \
|
||||
--tag "${IMAGE}" \
|
||||
--file "${BZOD_ROOT}/Dockerfile" \
|
||||
"${BZOD_ROOT}"; then
|
||||
die "Unable to build ${IMAGE}"
|
||||
fi
|
||||
|
||||
success "✓ Docker image built locally"
|
||||
|
||||
# ============================================================
|
||||
# 7. Deploy
|
||||
# ============================================================
|
||||
|
||||
info "[7/8] Deploying BZOD..."
|
||||
|
||||
cd "${COMPOSE_DIR}"
|
||||
|
||||
# Stop/remove the existing container through Compose.
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
down \
|
||||
--remove-orphans
|
||||
|
||||
# Start the requested image.
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
up -d
|
||||
|
||||
success "✓ BZOD container started"
|
||||
|
||||
# ============================================================
|
||||
# 8. Validation
|
||||
# ============================================================
|
||||
|
||||
info "[8/8] Validating deployment..."
|
||||
|
||||
sleep 5
|
||||
|
||||
if ! docker inspect \
|
||||
--format '{{.State.Running}}' \
|
||||
"${CONTAINER_NAME}" 2>/dev/null | grep -q '^true$'; then
|
||||
|
||||
error "BZOD container failed to start."
|
||||
echo
|
||||
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
logs --tail=100
|
||||
|
||||
error
|
||||
error "Deployment failed. Existing data was not removed."
|
||||
error "Backup: ${BACKUP_DIR}"
|
||||
|
||||
if ! systemctl is-active --quiet bzod; then
|
||||
echo -e "${RED}Service failed to start! Rolling back...${NC}"
|
||||
if [ -f "${INSTALL_PATH}.bak" ]; then
|
||||
install -m 755 "${INSTALL_PATH}.bak" "${INSTALL_PATH}"
|
||||
systemctl restart bzod || true
|
||||
fi
|
||||
journalctl -u bzod -n 50 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up backup on success
|
||||
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
|
||||
success "✓ Container is running"
|
||||
|
||||
# Soft health check
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ HTTP health check passed${NC}"
|
||||
else
|
||||
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
|
||||
# ------------------------------------------------------------
|
||||
# Health check
|
||||
# ------------------------------------------------------------
|
||||
|
||||
HEALTH_OK=0
|
||||
|
||||
for _ in {1..12}; do
|
||||
if curl -fsS \
|
||||
"http://127.0.0.1:${BZOD_PORT}/status" \
|
||||
>/dev/null 2>&1; then
|
||||
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "${HEALTH_OK}" -eq 1 ]]; then
|
||||
success "✓ HTTP health check passed"
|
||||
else
|
||||
warning "⚠ HTTP health check did not respond yet"
|
||||
warning "The container is running; inspect logs if necessary:"
|
||||
echo
|
||||
echo " docker compose -f ${COMPOSE_FILE} logs --tail=100"
|
||||
fi
|
||||
|
||||
# Final Message
|
||||
IP=$(hostname -I | awk '{print $1}' | head -n1)
|
||||
echo -e "\n${GREEN}=== BZOD Deployed Successfully! ===${NC}"
|
||||
echo -e "🌐 Web UI: http://${IP}:8654"
|
||||
echo -e "🔑 Admin: http://${IP}:8654/admin"
|
||||
echo -e "🖥 Architecture: ${ARCH}"
|
||||
echo -e "📦 Version: ${VERSION}"
|
||||
echo -e "\nNext step (first install):"
|
||||
echo -e " sudo -u bzod bzod create-admin"
|
||||
echo -e "\nCommands:"
|
||||
echo -e " journalctl -u bzod -f"
|
||||
echo -e " bzod doctor"
|
||||
echo -e " systemctl status bzod"
|
||||
# ============================================================
|
||||
# Verify image and binary
|
||||
# ============================================================
|
||||
|
||||
echo -e "\n${GREEN}Enjoy your lightweight, privacy-first, self-hosted URL shortener!${NC}"
|
||||
echo
|
||||
info "Installed image:"
|
||||
docker image inspect "${IMAGE}" \
|
||||
--format ' {{.RepoTags}} ({{.Id}})' \
|
||||
2>/dev/null || true
|
||||
|
||||
echo
|
||||
info "Container:"
|
||||
docker inspect "${CONTAINER_NAME}" \
|
||||
--format ' {{.Name}} {{.Config.Image}}' \
|
||||
2>/dev/null || true
|
||||
|
||||
echo
|
||||
info "Persistent mounts:"
|
||||
docker inspect "${CONTAINER_NAME}" \
|
||||
--format '{{range .Mounts}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' \
|
||||
2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# Final status
|
||||
# ============================================================
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo -e "${GREEN} BZOD ${BZOD_VERSION} deployed successfully${NC}"
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo
|
||||
|
||||
echo "Web UI:"
|
||||
echo " http://<server-ip>:${BZOD_PORT}"
|
||||
|
||||
echo
|
||||
echo "Persistent data:"
|
||||
echo " ${DATA_DIR}"
|
||||
|
||||
echo
|
||||
echo "Persistent images:"
|
||||
echo " ${IMAGES_DIR}"
|
||||
|
||||
echo
|
||||
echo "Docker Compose:"
|
||||
echo " ${COMPOSE_FILE}"
|
||||
|
||||
echo
|
||||
echo "Backup:"
|
||||
echo " ${BACKUP_DIR}"
|
||||
|
||||
echo
|
||||
echo "Useful commands:"
|
||||
echo " docker compose -f ${COMPOSE_FILE} ps"
|
||||
echo " docker compose -f ${COMPOSE_FILE} logs -f bzod"
|
||||
echo " docker compose -f ${COMPOSE_FILE} restart bzod"
|
||||
|
||||
echo
|
||||
success "Deployment complete."
|
||||
@@ -1,55 +1,87 @@
|
||||
name: app-bzod
|
||||
|
||||
services:
|
||||
bzod:
|
||||
build:
|
||||
context: /DATA/AppData/bzod
|
||||
context: /DATA/AppData/nx9-url-shortener
|
||||
dockerfile: Dockerfile
|
||||
cpu_shares: 90
|
||||
command: []
|
||||
|
||||
container_name: bzod
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 31940M
|
||||
|
||||
environment:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- CONFIG_DIR=/app/config
|
||||
- COOKIE_SECURE=false
|
||||
- DATA_DIR=/app/data
|
||||
- HOST=0.0.0.0
|
||||
- IMAGES_DIR=/app/images
|
||||
- PORT=8654
|
||||
- RUST_LOG=info
|
||||
- BASE_URL=${BASE_URL}
|
||||
|
||||
hostname: bzod
|
||||
image: nx9-url-shortener:v0.6.0
|
||||
|
||||
image: nx9-url-shortener:v0.7.0
|
||||
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 8654
|
||||
published: "8654"
|
||||
protocol: tcp
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/data
|
||||
source: /DATA/AppData/nx9-url-shortener/data
|
||||
target: /app/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/config
|
||||
source: /DATA/AppData/nx9-url-shortener/config
|
||||
target: /app/config
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/www
|
||||
source: /DATA/AppData/nx9-url-shortener/www
|
||||
target: /app/www
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/images
|
||||
source: /DATA/AppData/nx9-url-shortener/images
|
||||
target: /app/images
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
devices: []
|
||||
|
||||
cap_add: []
|
||||
|
||||
command: []
|
||||
|
||||
networks:
|
||||
- default
|
||||
|
||||
privileged: false
|
||||
|
||||
cpu_shares: 90
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: app_default
|
||||
|
||||
x-casaos:
|
||||
author: self
|
||||
category: self
|
||||
@@ -60,4 +92,4 @@ x-casaos:
|
||||
port_map: "8654"
|
||||
scheme: http
|
||||
title:
|
||||
custom: nx9-url-shortener
|
||||
custom: nx9-url-shortener
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ "$1" = 'serve' ]; then
|
||||
/usr/local/bin/bzod init-admin
|
||||
exec /usr/local/bin/bzod serve
|
||||
fi
|
||||
|
||||
exec /usr/local/bin/bzod "$@"
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Administrator Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Architecture Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backup & Restore Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
Applies To: BZOD Multi-User Platform
|
||||
|
||||
---
|
||||
|
||||
@@ -6,6 +6,51 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
|
||||
---
|
||||
|
||||
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
## Added
|
||||
|
||||
### Responsive UI
|
||||
|
||||
* Responsive layouts for admin and user URL registry panels
|
||||
* Responsive layouts for admin and user landing page registry panels
|
||||
* Desktop, laptop, tablet, and mobile layout support
|
||||
* Table-to-card responsive behavior for registry panels
|
||||
* Resolved horizontal scrolling issues in registry panels
|
||||
|
||||
### Theme Support
|
||||
|
||||
* Dark/light theme toggle
|
||||
* Theme persistence across sessions
|
||||
* Responsive theme behavior across device sizes
|
||||
|
||||
### Build Metadata
|
||||
|
||||
* Introduced `build.rs` build script for compile-time metadata
|
||||
* Introduced `src/build_info.rs` module exposing `APP_VERSION` and `GIT_COMMIT`
|
||||
* Application version derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`
|
||||
* Git commit hash (12-char short) embedded at build time via `BZOD_GIT_COMMIT`
|
||||
* Graceful fallback to `"unknown"` when Git metadata is unavailable
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
* Root `/` serves `www/index.html` with runtime file and embedded fallback behavior
|
||||
* Public/runtime www assets supported by the deployment layout
|
||||
|
||||
## Changed
|
||||
|
||||
* Documentation updated to reflect v0.7.0 current state
|
||||
* Version metadata updated across Cargo.toml, deploy.sh, and docker-compose.yml
|
||||
|
||||
## Notes
|
||||
|
||||
* No API behavior changes
|
||||
* No database schema changes
|
||||
* No authentication or security behavior changes
|
||||
* Existing redirect, routing, and tenant isolation behavior preserved
|
||||
|
||||
---
|
||||
|
||||
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
- **Legacy Backup Restore**: Full backward-compatible restore support for `legacy_flat_backup` archives into the current multi-tenant database architecture
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
|
||||
|
||||
The current command list for BZOD v0.6.0 is:
|
||||
The current command list for BZOD v0.7.0 is:
|
||||
|
||||
```text
|
||||
$ bzod --help
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# BZOD v0.6.0 vs Self-Hosted URL Management Platforms
|
||||
# BZOD v0.7.0 vs Self-Hosted URL Management Platforms
|
||||
|
||||
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# BZOD Database Architecture
|
||||
|
||||
BZOD v0.6.0 uses SQLite exclusively.
|
||||
BZOD v0.7.0 uses SQLite exclusively.
|
||||
|
||||
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
|
||||
|
||||
|
||||
@@ -45,7 +45,21 @@ cd nx9-url-shortener
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Create Administrator
|
||||
## Automated Administrator Bootstrap (First Start Only)
|
||||
|
||||
For fresh deployments, you can supply administrator credentials via environment variables so the container initializes the admin automatically:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
ADMIN_USERNAME: "admin"
|
||||
ADMIN_PASSWORD: "<your-secure-password>"
|
||||
```
|
||||
|
||||
These credentials are used **only** when no administrator exists. If an administrator is already present, this step is safely skipped and existing accounts are preserved.
|
||||
|
||||
## Manual Administrator Creation
|
||||
|
||||
Alternatively, if you prefer not to use environment variables, you can create the admin manually:
|
||||
|
||||
```bash
|
||||
docker exec -it bzod bzod create-admin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Installation Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
@@ -183,13 +183,13 @@ sudo pacman -S \
|
||||
Example:
|
||||
|
||||
```bash
|
||||
wget https://example.com/bzod-v0.6.0-linux-amd64.tar.gz
|
||||
wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Extract:
|
||||
|
||||
```bash
|
||||
tar -xzf bzod-v0.6.0-linux-amd64.tar.gz
|
||||
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Install:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Multi-User Architecture Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,49 @@
|
||||
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
Release Date: 2026-08-11
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Responsive UI**: Admin and user registry panels (URLs and landing pages) now adapt across desktop, laptop, tablet, and mobile viewports. Tables switch to card layouts on smaller screens, and horizontal scrolling issues in registry panels have been resolved.
|
||||
|
||||
- **Dark/Light Theme Support**: A dark/light theme toggle has been implemented with theme persistence across sessions and responsive behavior across device sizes.
|
||||
|
||||
- **Build Metadata**: The application now exposes its actual Cargo package version and, when available, the Git commit hash. This is powered by `build.rs` (compile-time Git commit extraction) and `src/build_info.rs` (exposing `APP_VERSION` and `GIT_COMMIT` constants). The version is derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`, and the Git commit hash falls back gracefully to `"unknown"` when unavailable.
|
||||
|
||||
- **Public Landing Page Serving**: Root `/` now serves the public `www/index.html` with runtime file detection and embedded fallback behavior. Public/runtime www assets are supported by the deployment layout.
|
||||
|
||||
## User-Visible Changes
|
||||
|
||||
- Admin URL registry panel is responsive across all device sizes
|
||||
- Admin landing page registry panel is responsive across all device sizes
|
||||
- User URL registry panel is responsive across all device sizes
|
||||
- User landing page registry panel is responsive across all device sizes
|
||||
- Dark/light theme toggle available in the UI
|
||||
- Theme preference persists across sessions
|
||||
- Registry tables switch to card layouts on tablet and mobile viewports
|
||||
- Horizontal scrolling eliminated from registry panels
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Introduced `build.rs` build script for compile-time metadata extraction
|
||||
- Introduced `src/build_info.rs` module with `APP_VERSION` and `GIT_COMMIT` constants
|
||||
- Version and Git commit now available to system status and admin settings endpoints
|
||||
- Root `/` serves `www/index.html` with runtime/embedded fallback
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- Direct upgrade from v0.6.0 with no migration required
|
||||
- No database schema changes
|
||||
- No API changes
|
||||
- No configuration changes
|
||||
- No breaking changes to existing functionality
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
Release Date: 2026-08-09
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Security Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a
|
||||
* Disaster recovery
|
||||
* Operational simplicity
|
||||
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.6.0.
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.7.0.
|
||||
|
||||
---
|
||||
|
||||
@@ -620,7 +620,7 @@ If compromise is suspected:
|
||||
|
||||
# Security Testing
|
||||
|
||||
BZOD v0.6.0 includes tests covering:
|
||||
BZOD v0.7.0 includes tests covering:
|
||||
|
||||
* Authentication
|
||||
* Authorization
|
||||
@@ -665,7 +665,7 @@ These may be addressed in future releases.
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD v0.6.0 provides:
|
||||
BZOD v0.7.0 provides:
|
||||
|
||||
* Centralized authentication
|
||||
* Secure session management
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Upgrade Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 187 KiB |
@@ -0,0 +1,5 @@
|
||||
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const GIT_COMMIT: &str = match option_env!("BZOD_GIT_COMMIT") {
|
||||
Some(commit) => commit,
|
||||
None => "unknown",
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::auth::hash_password;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
let conn = db.users.lock().unwrap();
|
||||
|
||||
let admin_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
if admin_count > 0 {
|
||||
info!("Administrator already exists; initialization skipped.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let username = match env::var("ADMIN_USERNAME") {
|
||||
Ok(u) if !u.trim().is_empty() => u.trim().to_string(),
|
||||
_ => {
|
||||
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
|
||||
error!("{}", msg);
|
||||
return Err(msg.into());
|
||||
}
|
||||
};
|
||||
|
||||
let password = match env::var("ADMIN_PASSWORD") {
|
||||
Ok(p) if !p.trim().is_empty() => p.trim().to_string(),
|
||||
_ => {
|
||||
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
|
||||
error!("{}", msg);
|
||||
return Err(msg.into());
|
||||
}
|
||||
};
|
||||
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
let u = crate::db::users::create_admin_user(&conn, &username, &hash)?;
|
||||
db.init_user_databases(u.id)?;
|
||||
info!("Administrator initialized successfully.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod disable_user;
|
||||
pub mod doctor;
|
||||
pub mod enable_user;
|
||||
pub mod expand;
|
||||
pub mod init_admin;
|
||||
pub mod list_users;
|
||||
pub mod migrate;
|
||||
pub mod repair;
|
||||
@@ -86,6 +87,11 @@ pub enum Commands {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Initialize the first administrator for automated/container deployments
|
||||
InitAdmin {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Run database diagnostics and health checks
|
||||
Doctor {
|
||||
#[arg(long)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod analytics;
|
||||
pub mod auth;
|
||||
pub mod build_info;
|
||||
pub mod charts;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
|
||||
@@ -44,6 +44,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Commands::CreateAdmin { username, data_dir } => {
|
||||
bzod::cli::create_admin::run(username, data_dir, config).await?;
|
||||
}
|
||||
Commands::InitAdmin { data_dir } => {
|
||||
bzod::cli::init_admin::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::Doctor { data_dir } => {
|
||||
bzod::cli::doctor::run(data_dir, config).await?;
|
||||
}
|
||||
|
||||
@@ -773,8 +773,8 @@ pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Respon
|
||||
queue_size,
|
||||
memory_usage,
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
urls,
|
||||
};
|
||||
|
||||
@@ -825,8 +825,8 @@ pub async fn user_status_get(State(state): State<AppState>, jar: CookieJar) -> R
|
||||
queue_size,
|
||||
memory_usage,
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
urls,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::path::{Component, PathBuf};
|
||||
|
||||
const IMAGES_DIR: &str = "/app/images";
|
||||
|
||||
pub async fn image_handler(Path(path): Path<String>) -> Response {
|
||||
// preview.png is reserved for social-media metadata.
|
||||
// It is served from /app/www/images/preview.png,
|
||||
// not from the persistent application image directory.
|
||||
if path == "preview.png" {
|
||||
return crate::web::pages::social_preview().await;
|
||||
}
|
||||
|
||||
let relative = PathBuf::from(&path);
|
||||
|
||||
// Prevent path traversal.
|
||||
if relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
}) {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid image path").into_response();
|
||||
}
|
||||
|
||||
let image_path = PathBuf::from(IMAGES_DIR).join(relative);
|
||||
|
||||
let content = match tokio::fs::read(&image_path).await {
|
||||
Ok(content) => content,
|
||||
Err(_) => {
|
||||
return (StatusCode::NOT_FOUND, "Image not found").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let content_type = match image_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("avif") => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
let content_type = HeaderValue::from_static(content_type);
|
||||
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=86400"),
|
||||
),
|
||||
],
|
||||
content,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -11,3 +11,4 @@ pub mod routes;
|
||||
pub mod system;
|
||||
|
||||
pub use routes::create_router;
|
||||
pub mod images;
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::state::AppState;
|
||||
use crate::web::{admin, api, bulk, multi_user, pages, password_gate, qr, redirect, system};
|
||||
use crate::web::{
|
||||
admin, api, bulk, images, multi_user, pages, password_gate, qr, redirect, system,
|
||||
};
|
||||
use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
@@ -95,7 +97,7 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.route("/admin/pages/delete/:id", post(admin::pages_delete))
|
||||
.route("/admin/analytics/url/:id", get(admin::url_analytics_get))
|
||||
.route("/deploy.sh", get(pages::deploy_script))
|
||||
.route("/images/preview.png", get(pages::social_preview))
|
||||
.route("/images/*path", get(images::image_handler))
|
||||
.route(
|
||||
"/admin/analytics/url/:id/export/csv",
|
||||
get(admin::url_analytics_csv_export),
|
||||
|
||||
@@ -79,8 +79,8 @@ pub async fn status_endpoint(
|
||||
queue_size: 0,
|
||||
memory_usage: get_memory_usage(),
|
||||
uptime_seconds: uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Landing Page Form -->
|
||||
<div class="urls-dashboard">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@@ -24,7 +23,7 @@
|
||||
<form action="/admin/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div class="page-form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
@@ -36,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
|
||||
<div class="page-form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
@@ -49,7 +48,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state">
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
@@ -59,16 +58,15 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="html_content">Raw HTML Document *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="font-family: monospace; font-size: 0.85rem; height: 300px; background-color: #05070f; border-color: rgba(255,255,255,0.05);" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Landing Pages Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Registered Pages
|
||||
@@ -76,69 +74,75 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-pages-table">
|
||||
<colgroup>
|
||||
<col class="col-page-title">
|
||||
<col class="col-page-paths">
|
||||
<col class="col-page-status">
|
||||
<col class="col-page-qr">
|
||||
<col class="col-page-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Page</th>
|
||||
<th>Paths</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>QR Code</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
<td data-label="Page">
|
||||
<div class="page-title-block">
|
||||
<div class="page-title-heading">{{ page.title }}</div>
|
||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
<td data-label="Paths">
|
||||
<div class="page-paths">
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status" class="status-cell">
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -147,8 +151,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
@media (max-width: 720px) {
|
||||
#utm_fields > div {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}Short URL Registry{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -13,9 +21,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Link Form -->
|
||||
<div class="card">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card url-form-card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Shorten a New URL
|
||||
@@ -97,9 +104,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Links Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
Registered Links
|
||||
@@ -113,7 +119,15 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-links-table">
|
||||
<colgroup>
|
||||
<col class="col-short">
|
||||
<col class="col-destination">
|
||||
<col class="col-qr">
|
||||
<col class="col-health">
|
||||
<col class="col-tags">
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
@@ -121,108 +135,112 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No shortened URLs registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" style="color: #fbbf24; display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem; font-weight: 500;">
|
||||
{{ url.title.as_deref().unwrap_or("") }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
<a href="{{ url.destination }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem;" title="{{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">
|
||||
{{ desc }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div style="font-size: 0.75rem; color: #fca5a5; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div style="font-size: 0.75rem; color: #38bdf8; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
<td data-label="Short Link">
|
||||
<div class="short-link-wrap">
|
||||
<div class="short-link-primary">
|
||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% if let Some(title) = url.title.as_deref() %}
|
||||
{% if !title.is_empty() %}
|
||||
<div class="short-link-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Destination">
|
||||
<div class="destination-block">
|
||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div class="destination-meta expiry">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div class="destination-meta access">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div class="destination-meta neutral">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td>
|
||||
<td data-label="Health" class="status-cell">
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.25rem; flex-wrap: wrap; max-width: 150px;">
|
||||
{% for t in url.tags %}
|
||||
<a href="/admin/urls?tag={{ t }}" class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary); text-decoration: none; font-size: 0.7rem; border: 1px solid var(--border-color);">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<td data-label="Tags">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span class="tags-empty">—</span>
|
||||
{% else %}
|
||||
<div class="tags-wrap">
|
||||
{% for t in url.tags %}
|
||||
<a href="/admin/urls?tag={{ t }}" class="badge tag-badge">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ url.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -230,8 +248,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@@ -23,40 +23,44 @@
|
||||
<form action="/user/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
<div class="page-form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<div class="page-form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="html_content">HTML Content *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="height: 260px; font-family: monospace;" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">Save Page</button>
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
My Landing Pages
|
||||
@@ -64,69 +68,75 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-pages-table">
|
||||
<colgroup>
|
||||
<col class="col-page-title">
|
||||
<col class="col-page-paths">
|
||||
<col class="col-page-status">
|
||||
<col class="col-page-qr">
|
||||
<col class="col-page-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Page</th>
|
||||
<th>Paths</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>QR Code</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
<td data-label="Page">
|
||||
<div class="page-title-block">
|
||||
<div class="page-title-heading">{{ page.title }}</div>
|
||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
<td data-label="Paths">
|
||||
<div class="page-paths">
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status" class="status-cell">
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -135,7 +145,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="card">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card url-form-card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create a New URL
|
||||
@@ -47,20 +47,34 @@
|
||||
<label for="tags">Tags (comma-separated, optional)</label>
|
||||
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Link</button>
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
My Short Links
|
||||
</h3>
|
||||
{% if let Some(tag) = tag_filter %}
|
||||
<span class="badge badge-healthy" style="text-transform: none;">
|
||||
Filtered by tag: {{ tag }}
|
||||
<a href="/user/urls" style="color: inherit; text-decoration: none; margin-left: 0.5rem; font-weight: bold;">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-links-table">
|
||||
<colgroup>
|
||||
<col class="col-short">
|
||||
<col class="col-destination">
|
||||
<col class="col-qr">
|
||||
<col class="col-health">
|
||||
<col class="col-tags">
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
@@ -68,36 +82,111 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No short URLs created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
|
||||
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
|
||||
<td data-label="Short Link">
|
||||
<div class="short-link-wrap">
|
||||
<div class="short-link-primary">
|
||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(title) = url.title.as_deref() %}
|
||||
{% if !title.is_empty() %}
|
||||
<div class="short-link-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Destination">
|
||||
<div class="destination-block">
|
||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div class="destination-meta expiry">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div class="destination-meta access">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div class="destination-meta neutral">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td>{{ url.status }}</td>
|
||||
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
|
||||
<td>
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">View</a>
|
||||
<td data-label="Health" class="status-cell">
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ url.created_at[0..10] }}</td>
|
||||
<td>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem;">Delete</button>
|
||||
</form>
|
||||
<td data-label="Tags">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span class="tags-empty">—</span>
|
||||
{% else %}
|
||||
<div class="tags-wrap">
|
||||
{% for t in url.tags %}
|
||||
<a href="/user/urls?tag={{ t }}" class="badge tag-badge">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.75rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -106,10 +195,10 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/urls?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
<a href="/user/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< Prev</span>
|
||||
@@ -119,13 +208,13 @@
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="/user/urls?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
<a href="/user/urls?page={{ p }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/user/urls?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
<a href="/user/urls?page={{ current_page + 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
use bzod::config::Config;
|
||||
use bzod::db::Db;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.clone();
|
||||
config.base_url = Some("http://bzo.in".to_string());
|
||||
config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_admin() {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_test_init_admin_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
// Case C/D - missing env vars
|
||||
env::remove_var("ADMIN_USERNAME");
|
||||
env::remove_var("ADMIN_PASSWORD");
|
||||
let res = bzod::cli::init_admin::run(None, config.clone()).await;
|
||||
assert!(res.is_err(), "Should fail without env vars");
|
||||
|
||||
// Case A - No admin + valid ENV
|
||||
env::set_var("ADMIN_USERNAME", "admin");
|
||||
env::set_var("ADMIN_PASSWORD", "securepass");
|
||||
let res = bzod::cli::init_admin::run(None, config.clone()).await;
|
||||
assert!(res.is_ok(), "Should succeed with valid env vars");
|
||||
|
||||
let db = Db::init(&config).unwrap();
|
||||
{
|
||||
let conn = db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_username(&conn, "admin")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(user.account_type, "admin");
|
||||
assert!(bzod::auth::verify_password(
|
||||
"securepass",
|
||||
&user.password_hash
|
||||
));
|
||||
}
|
||||
|
||||
// Case B/F - Admin already exists
|
||||
env::remove_var("ADMIN_USERNAME");
|
||||
env::remove_var("ADMIN_PASSWORD");
|
||||
let res = bzod::cli::init_admin::run(None, config.clone()).await;
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"Should skip and succeed if admin exists even without env vars"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||