7 Commits
Author SHA1 Message Date
thakares f138a645f8 fix: configure production base URL
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-13 12:23:39 +05:30
thakares ac66945c93 docs: refresh v0.7.1 UI screenshots
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 20:02:40 +05:30
thakares 763a17f8dc fix: update CasaOS production deployment
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 19:08:06 +05:30
thakares 8e0bcbe580 feat: add Docker first-start admin bootstrap
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 14:13:09 +05:30
thakares 25577b4b83 feat: finalize v0.7.0 Docker deployment and image routing
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 13:02:44 +05:30
thakares 2cd3c2d965 Release v0.7.0 documentation and version update
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-11 11:33:33 +05:30
thakares b66703082f Refactor responsive UI and build metadata
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-10 17:46:49 +05:30
49 changed files with 2495 additions and 580 deletions

No files matched your search

+3 -3
View File
@@ -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
+1
View File
@@ -6,3 +6,4 @@ data/
.env
.idea/
bzod.env
Generated
+1 -1
View File
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzod"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"argon2",
"askama",
+1 -1
View File
@@ -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"
+55 -33
View File
@@ -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"]
+35 -15
View File
@@ -6,7 +6,7 @@
![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue)
![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey)
![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green)
![Version](https://img.shields.io/badge/Version-v0.6.0-purple)
![Version](https://img.shields.io/badge/Version-v0.7.0-purple)
[![GitHub](https://img.shields.io/badge/GitHub-thakares%2Fbzod-181717?logo=github)](https://github.com/thakares/bzod)
[![Codeberg](https://img.shields.io/badge/Codeberg-thakares%2Fbzod-2185D0?logo=codeberg)](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
---
+18
View File
@@ -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}");
}
}
}
}
+412 -188
View File
@@ -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."
+41 -9
View File
@@ -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
+9
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# BZOD Administrator Guide
Version: v0.6.0
Version: v0.7.0
---
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Architecture Guide
Version: v0.6.0
Version: v0.7.0
---
+1 -1
View File
@@ -1,6 +1,6 @@
# Backup & Restore Guide
Version: v0.6.0
Version: v0.7.0
Applies To: BZOD Multi-User Platform
---
+45
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+15 -1
View File
@@ -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
+3 -3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# BZOD Multi-User Architecture Guide
Version: v0.6.0
Version: v0.7.0
---
+46
View File
@@ -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
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+5
View File
@@ -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",
};
+53
View File
@@ -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(())
}
+6
View File
@@ -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
View File
@@ -1,5 +1,6 @@
pub mod analytics;
pub mod auth;
pub mod build_info;
pub mod charts;
pub mod cli;
pub mod config;
+3
View File
@@ -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?;
}
+4 -4
View File
@@ -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,
};
+67
View File
@@ -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()
}
+1
View File
@@ -11,3 +11,4 @@ pub mod routes;
pub mod system;
pub use routes::create_router;
pub mod images;
+4 -2
View File
@@ -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),
+2 -2
View File
@@ -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()
}
+1242 -72
View File
File diff suppressed because it is too large. Load diff
+53 -50
View File
@@ -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>&#10;<html>&#10;<head>&#10; <title>Landing Page</title>&#10;</head>&#10;<body>&#10; <h1>Welcome!</h1>&#10;</body>&#10;</html>" required></textarea>
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>Landing Page</title>&#10;</head>&#10;<body>&#10; <h1>Welcome!</h1>&#10;</body>&#10;</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;">&lt;&lt; First</a>
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
+105 -88
View File
@@ -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;">&lt;&lt; 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;">&lt; Prev</a>
+77 -67
View File
@@ -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>&#10;<html>&#10;<head>&#10; <title>My Page</title>&#10;</head>&#10;<body>&#10; <h1>Hello</h1>&#10;</body>&#10;</html>" required></textarea>
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>My Page</title>&#10;</head>&#10;<body>&#10; <h1>Hello</h1>&#10;</body>&#10;</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;">&lt;&lt; First</a>
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
+117 -28
View File
@@ -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;">&times;</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;">&lt;&lt; First</a>
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; 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;">&lt;&lt; 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;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt; 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 &gt;</a>
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last &gt;&gt;</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 &gt;</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 &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last &gt;&gt;</span>
+57
View File
@@ -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);
}