5 Commits
Author SHA1 Message Date
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
39 changed files with 2379 additions and 476 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 SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
# Bootstrap Admin Credentials # Bootstrap Admin Credentials
# Default username: admin
# Default password: admin
ADMIN_USERNAME=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) # Cron & Cleaner Intervals (in minutes)
LINK_CHECK_INTERVAL_MINS=60 LINK_CHECK_INTERVAL_MINS=60
+1
View File
@@ -6,3 +6,4 @@ data/
.env .env
.idea/ .idea/
bzod.env
Generated
+1 -1
View File
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "bzod" name = "bzod"
version = "0.6.0" version = "0.7.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"askama", "askama",
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "bzod" name = "bzod"
description = "Self-hosted multi-user URL management, landing page and QR analytics platform" description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
version = "0.6.0" version = "0.7.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/thakares/nx9-url-shortener" repository = "https://github.com/thakares/nx9-url-shortener"
+54 -32
View File
@@ -1,73 +1,95 @@
# ========================================== # ==========================================
# Stage 1: Builder (with optimized caching) # Stage 1: Builder
# ========================================== # ==========================================
FROM rust:1.89-bookworm AS builder FROM rust:1.89-bookworm AS builder
WORKDIR /app WORKDIR /app
# Install build dependencies # Build dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
&& rm -rf /var/lib/apt/lists/* && 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 ./ COPY Cargo.toml Cargo.lock ./
# Create dummy source for dependency caching # Dummy build to cache Rust dependencies
RUN mkdir -p src && \ RUN mkdir -p src && \
echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \ printf 'fn main() {}\n' > src/main.rs && \
cargo build --release && \ cargo build --release --locked && \
rm -rf src target/release/deps/bzod* rm -rf src
# Copy real source code + assets # Actual application source and runtime assets
COPY src ./src COPY src ./src
COPY templates ./templates COPY templates ./templates
COPY www ./www COPY www ./www
# Build the real application # Reproducible production build
RUN cargo build --release RUN cargo build --release --locked
# ========================================== # ==========================================
# Stage 2: Runtime (slim) # Stage 2: Runtime
# ========================================== # ==========================================
FROM debian:bookworm-slim FROM debian:bookworm-slim AS runtime
WORKDIR /app WORKDIR /app
# Runtime dependencies # Runtime dependencies only
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
openssl \
ca-certificates \ ca-certificates \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && 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 --from=builder /app/target/release/bzod /usr/local/bin/bzod
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Copy assets # Application-owned immutable assets
COPY --from=builder /app/templates ./templates COPY --from=builder /app/templates /app/templates
COPY --from=builder /app/www ./www COPY --from=builder /app/www /app/www
# Create non-root user # Persistent runtime directories.
RUN groupadd -g 1000 bzod && \ # /app/images is intentionally external/persistent in Compose.
useradd -u 1000 -g bzod -m -s /bin/bash bzod RUN mkdir -p \
/app/data \
# Create data directory /app/config \
RUN mkdir -p /app/data && \ /app/images && \
chown -R bzod:bzod /app chown -R bzod:bzod \
/app/data \
USER bzod /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 \ ENV DATA_DIR=/app/data \
CONFIG_DIR=/app/config \
IMAGES_DIR=/app/images \
PORT=8654 \ PORT=8654 \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
COOKIE_SECURE=true COOKIE_SECURE=true
EXPOSE 8654 EXPOSE 8654
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK \
CMD curl -f http://localhost:${PORT}/status || exit 1 --interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD curl -fsS "http://127.0.0.1:${PORT}/status" || exit 1
ENTRYPOINT ["bzod"] USER bzod
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["serve"] CMD ["serve"]
+35 -15
View File
@@ -6,7 +6,7 @@
![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue) ![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue)
![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey) ![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey)
![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green) ![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) [![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) [![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 - Multi-User Platform
- REST API - REST API
- Role-Based Access Control (RBAC) - Role-Based Access Control (RBAC)
- Responsive UI (Desktop, Tablet, Mobile)
- Dark/Light Theme Support
- Global Namespace Registry - Global Namespace Registry
- Registry Validation - Registry Validation
- Transaction-safe Registry Repair - Transaction-safe Registry Repair
@@ -44,6 +46,7 @@ Core capabilities include:
- User Backup & Restore - User Backup & Restore
- Disaster Recovery - Disaster Recovery
- Upgrade Validation - Upgrade Validation
- Build Metadata (Version & Git Commit)
- Single Binary Deployment - Single Binary Deployment
The result is a platform that is easy to deploy, lightweight to operate, and entirely controlled by its owner. 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 | | Metric | Value |
|---------|------:| |---------|------:|
@@ -1135,16 +1138,37 @@ Highlights include:
- Upgrade Validation - Upgrade Validation
- Backup Manifest - Backup Manifest
- Transaction-safe Maintenance - Transaction-safe Maintenance
- Modular Admin Architecture (v0.6.0) - Modular Admin Architecture (v0.5.3)
- Redirect Handler Hardening (v0.6.0) - 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 # Future Roadmap
## v0.6 Planned features and improvements:
Planned features include:
- Webhooks - Webhooks
- OpenAPI Documentation - OpenAPI Documentation
@@ -1155,13 +1179,6 @@ Planned features include:
- Bulk Export - Bulk Export
- Custom Domains - Custom Domains
- Better QR Styling - Better QR Styling
---
## v0.7
Planned improvements:
- Email Notifications - Email Notifications
- Team Workspaces - Team Workspaces
- Shared Projects - Shared Projects
@@ -1320,7 +1337,7 @@ Community feedback helps guide future development.
**Current Version** **Current Version**
**v0.5.3** **v0.7.0**
Production Ready Production Ready
@@ -1336,12 +1353,15 @@ Production Ready
- ✅ Analytics - ✅ Analytics
- ✅ REST API - ✅ REST API
- ✅ Role-Based Access Control - ✅ Role-Based Access Control
- ✅ Responsive UI (Desktop, Tablet, Mobile)
- ✅ Dark/Light Theme Support
- ✅ Registry Validator - ✅ Registry Validator
- ✅ Registry Repair Framework - ✅ Registry Repair Framework
- ✅ Health Diagnostics - ✅ Health Diagnostics
- ✅ Backup & Restore - ✅ Backup & Restore
- ✅ Disaster Recovery - ✅ Disaster Recovery
- ✅ Upgrade Validation - ✅ Upgrade Validation
- ✅ Build Metadata (Version & Git Commit)
- ✅ Extensive Automated Test Suite - ✅ 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}");
}
}
}
}
+398 -186
View File
@@ -1,251 +1,463 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# BZOD Production Deployment Script #
# BZOD Production Docker Deployment
#
# Privacy-First URL Shortener & Landing Page Platform
#
# Usage:
# curl -fsSL https://bzo.in/deploy.sh | sudo bash # curl -fsSL https://bzo.in/deploy.sh | sudo bash
#
# Or:
# sudo bash deploy.sh
#
# Environment overrides:
# BZOD_VERSION=0.7.0
# BZOD_IMAGE=nx9-url-shortener
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
# BZOD_PORT=8654
#
set -euo pipefail set -euo pipefail
BZOD_VERSION="0.6.0" # ============================================================
# Configuration
# ============================================================
SERVICE_USER="bzod" BZOD_VERSION="${BZOD_VERSION:-0.7.0}"
INSTALL_PATH="/usr/local/bin/bzod" BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
CONFIG_DIR="/etc/bzod" BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
DATA_DIR="/var/lib/bzod/data" BZOD_PORT="${BZOD_PORT:-8654}"
ENV_FILE="${CONFIG_DIR}/bzod.env"
SYSTEMD_UNIT="/etc/systemd/system/bzod.service" 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' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m' NC='\033[0m'
# Temporary file cleanup info() {
TMP_BINARY="" echo -e "${BLUE}$*${NC}"
cleanup() {
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
} }
trap cleanup EXIT
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}" success() {
echo -e "Production deployment started...\n" echo -e "${GREEN}$*${NC}"
}
if [ "$EUID" -ne 0 ]; then warning() {
echo -e "${RED}Error: This script must be run as root (use sudo).${NC}" echo -e "${YELLOW}$*${NC}"
}
error() {
echo -e "${RED}$*${NC}" >&2
}
die() {
error "$*"
exit 1 exit 1
}
# ============================================================
# Root check
# ============================================================
if [[ "${EUID}" -ne 0 ]]; then
die "This script must be run as root. Use: sudo bash deploy.sh"
fi fi
# 1. Install Base Dependencies echo
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}" 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
# ============================================================
# 1. Install Docker
# ============================================================
info "[1/8] Checking Docker..."
if ! command -v docker >/dev/null 2>&1; then
info "Docker is not installed. Installing Docker..."
apt-get update -qq apt-get update -qq
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip apt-get install -y \
ca-certificates \
curl
# 2. Install Binary (safe atomic download) install -m 0755 -d /etc/apt/keyrings
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
ARCH="$(uname -m)" if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
case $ARCH in curl -fsSL \
x86_64) BINARY_NAME="bzod-x86_64-unknown-linux-gnu" ;; https://download.docker.com/linux/debian/gpg \
aarch64|arm64) BINARY_NAME="bzod-aarch64-unknown-linux-gnu" ;; -o /etc/apt/keyrings/docker.asc
armv7l) BINARY_NAME="bzod-armv7-unknown-linux-gnueabihf" ;;
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
esac
REPO="thakares/nx9-url-shortener" chmod a+r /etc/apt/keyrings/docker.asc
RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${BINARY_NAME}"
TMP_BINARY=$(mktemp)
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
fi fi
if [ ! -s "${TMP_BINARY}" ]; then . /etc/os-release
echo -e "${BLUE}Falling back to local build...${NC}"
if ! command -v cargo >/dev/null 2>&1; then echo \
echo -e "${RED}Neither pre-built binary nor cargo available.${NC}" "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
exit 1 https://download.docker.com/linux/debian \
fi ${VERSION_CODENAME} stable" \
apt-get install -y pkg-config build-essential > /etc/apt/sources.list.d/docker.list
cargo build --release
cp target/release/bzod "${TMP_BINARY}" apt-get update -qq
echo -e "${GREEN}✓ Built from source${NC}"
fi apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
fi fi
# Atomic replace with backup if ! docker info >/dev/null 2>&1; then
if [ -f "${INSTALL_PATH}" ]; then systemctl enable --now docker
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
fi fi
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}" if ! docker compose version >/dev/null 2>&1; then
die "Docker Compose plugin is unavailable."
# Verify
if [ ! -x "${INSTALL_PATH}" ]; then
echo -e "${RED}Binary installation failed${NC}"
exit 1
fi fi
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || { success "✓ Docker and Docker Compose available"
echo -e "${RED}Binary verification failed${NC}"
exit 1
}
# Verify -V also works # ============================================================
"${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || { # 2. Create persistent directories
echo -e "${RED}Binary -V verification failed${NC}" # ============================================================
exit 1
}
# Show installed version and verify it matches requested version info "[2/8] Creating persistent application directories..."
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}"
# 3. Create System User mkdir -p \
echo -e "\n${BLUE}[3/8] Creating system user '${SERVICE_USER}'...${NC}" "${DATA_DIR}" \
if ! id -u "${SERVICE_USER}" &>/dev/null; then "${CONFIG_DIR}" \
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}" "${IMAGES_DIR}" \
fi "${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 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 HOST=0.0.0.0
PORT=8654 PORT=8654
DATA_DIR=${DATA_DIR}
DATA_DIR=/app/data
CONFIG_DIR=/app/config
IMAGES_DIR=/app/images
COOKIE_SECURE=true COOKIE_SECURE=true
RUST_LOG=info RUST_LOG=info
SESSION_SECRET=$(openssl rand -hex 32)
EOF EOF
chmod 600 "${ENV_FILE}" chmod 600 "${ENV_FILE}"
chown root:"${SERVICE_USER}" "${ENV_FILE}"
success "✓ New Docker configuration created"
else 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
fi fi
# 6. Systemd Service # ============================================================
echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}" # 4. Create Compose definition
cat <<EOF > "${SYSTEMD_UNIT}" # ============================================================
[Unit]
Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
After=network-online.target
Wants=network-online.target
[Service] info "[4/8] Writing Docker Compose configuration..."
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_USER}
WorkingDirectory=/var/lib/bzod
EnvironmentFile=${ENV_FILE}
ExecStart=${INSTALL_PATH} serve
Restart=on-failure cat > "${COMPOSE_FILE}" <<'EOF'
RestartSec=5s services:
# Security Hardening bzod:
ProtectSystem=strict image: ${BZOD_IMAGE}:${BZOD_VERSION}
ProtectHome=yes container_name: bzod
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
LockPersonality=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/bzod
[Install] restart: unless-stopped
WantedBy=multi-user.target
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}"
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 EOF
chmod 644 "${SYSTEMD_UNIT}" # Append BZOD_ROOT because compose needs it.
systemctl daemon-reload if ! grep -q '^BZOD_ROOT=' "${ENV_FILE}"; then
echo "BZOD_ROOT=${BZOD_ROOT}" >> "${ENV_FILE}"
# 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 fi
BACKUP_DIR="/var/lib/bzod/pre-upgrade-backup-v${BZOD_VERSION}" # Port variable expected by compose.
if ! grep -q '^PORT=' "${ENV_FILE}"; then
echo "PORT=${BZOD_PORT}" >> "${ENV_FILE}"
fi
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}" 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 if [[ -d "${DATA_DIR}" ]]; then
echo -e "${GREEN} ✓ Pre-upgrade backup created at ${BACKUP_DIR}${NC}" cp -a "${DATA_DIR}" "${BACKUP_DIR}/data"
else
echo -e "${GREEN}✓ Fresh installation (databases will be created on first start)${NC}"
fi fi
systemctl enable --now bzod if [[ -d "${CONFIG_DIR}" ]]; then
cp -a "${CONFIG_DIR}" "${BACKUP_DIR}/config"
# 8. Validation + Rollback
sleep 3
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 fi
journalctl -u bzod -n 50 --no-pager
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}"
exit 1 exit 1
fi fi
# Clean up backup on success success "✓ Container is running"
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
# Soft health check # ------------------------------------------------------------
if command -v curl >/dev/null 2>&1; then # Health check
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then # ------------------------------------------------------------
echo -e "${GREEN}✓ HTTP health check passed${NC}"
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 else
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}" warning "⚠ HTTP health check did not respond yet"
fi warning "The container is running; inspect logs if necessary:"
echo
echo " docker compose -f ${COMPOSE_FILE} logs --tail=100"
fi fi
# Final Message # ============================================================
IP=$(hostname -I | awk '{print $1}' | head -n1) # Verify image and binary
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"
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."
+40 -8
View File
@@ -1,55 +1,87 @@
name: app-bzod name: app-bzod
services: services:
bzod: bzod:
build: build:
context: /DATA/AppData/bzod context: /DATA/AppData/nx9-url-shortener
dockerfile: Dockerfile dockerfile: Dockerfile
cpu_shares: 90
command: []
container_name: bzod container_name: bzod
deploy: deploy:
resources: resources:
limits: limits:
memory: 31940M memory: 31940M
environment: environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- CONFIG_DIR=/app/config
- COOKIE_SECURE=false - COOKIE_SECURE=false
- DATA_DIR=/app/data - DATA_DIR=/app/data
- HOST=0.0.0.0 - HOST=0.0.0.0
- IMAGES_DIR=/app/images
- PORT=8654 - PORT=8654
- RUST_LOG=info - RUST_LOG=info
- BASE_URL=${BASE_URL}
hostname: bzod hostname: bzod
image: nx9-url-shortener:v0.6.0
image: nx9-url-shortener:v0.7.0
ports: ports:
- mode: ingress - mode: ingress
target: 8654 target: 8654
published: "8654" published: "8654"
protocol: tcp protocol: tcp
restart: unless-stopped restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes: volumes:
- type: bind - type: bind
source: /DATA/AppData/bzod/data source: /DATA/AppData/nx9-url-shortener/data
target: /app/data target: /app/data
bind: bind:
create_host_path: true create_host_path: true
- type: bind - type: bind
source: /DATA/AppData/bzod/config source: /DATA/AppData/nx9-url-shortener/config
target: /app/config target: /app/config
bind: bind:
create_host_path: true create_host_path: true
- type: bind - type: bind
source: /DATA/AppData/bzod/www source: /DATA/AppData/nx9-url-shortener/www
target: /app/www target: /app/www
bind:
create_host_path: true
- type: bind - type: bind
source: /DATA/AppData/bzod/images source: /DATA/AppData/nx9-url-shortener/images
target: /app/images target: /app/images
bind:
create_host_path: true
devices: [] devices: []
cap_add: [] cap_add: []
command: []
networks: networks:
- default - default
privileged: false privileged: false
cpu_shares: 90
networks: networks:
default: default:
name: app_default name: app_default
x-casaos: x-casaos:
author: self author: self
category: self category: self
+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 # BZOD Administrator Guide
Version: v0.6.0 Version: v0.7.0
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Architecture Guide # BZOD Architecture Guide
Version: v0.6.0 Version: v0.7.0
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# Backup & Restore Guide # Backup & Restore Guide
Version: v0.6.0 Version: v0.7.0
Applies To: BZOD Multi-User Platform 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 # 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 - **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. 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 ```text
$ bzod --help $ 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. 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 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. 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 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 ```bash
docker exec -it bzod bzod create-admin docker exec -it bzod bzod create-admin
+3 -3
View File
@@ -1,6 +1,6 @@
# BZOD Installation Guide # BZOD Installation Guide
Version: v0.6.0 Version: v0.7.0
--- ---
@@ -183,13 +183,13 @@ sudo pacman -S \
Example: Example:
```bash ```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: Extract:
```bash ```bash
tar -xzf bzod-v0.6.0-linux-amd64.tar.gz tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
``` ```
Install: Install:
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Multi-User Architecture Guide # 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 # BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
Release Date: 2026-08-09 Release Date: 2026-08-09
+4 -4
View File
@@ -1,6 +1,6 @@
# BZOD Security Guide # 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 * Disaster recovery
* Operational simplicity * 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 # Security Testing
BZOD v0.6.0 includes tests covering: BZOD v0.7.0 includes tests covering:
* Authentication * Authentication
* Authorization * Authorization
@@ -665,7 +665,7 @@ These may be addressed in future releases.
# Summary # Summary
BZOD v0.6.0 provides: BZOD v0.7.0 provides:
* Centralized authentication * Centralized authentication
* Secure session management * Secure session management
+2 -2
View File
@@ -1,8 +1,8 @@
# Upgrade Guide # 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.
--- ---
+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 doctor;
pub mod enable_user; pub mod enable_user;
pub mod expand; pub mod expand;
pub mod init_admin;
pub mod list_users; pub mod list_users;
pub mod migrate; pub mod migrate;
pub mod repair; pub mod repair;
@@ -86,6 +87,11 @@ pub enum Commands {
#[arg(long)] #[arg(long)]
data_dir: Option<String>, 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 /// Run database diagnostics and health checks
Doctor { Doctor {
#[arg(long)] #[arg(long)]
+1
View File
@@ -1,5 +1,6 @@
pub mod analytics; pub mod analytics;
pub mod auth; pub mod auth;
pub mod build_info;
pub mod charts; pub mod charts;
pub mod cli; pub mod cli;
pub mod config; pub mod config;
+3
View File
@@ -44,6 +44,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::CreateAdmin { username, data_dir } => { Commands::CreateAdmin { username, data_dir } => {
bzod::cli::create_admin::run(username, data_dir, config).await?; 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 } => { Commands::Doctor { data_dir } => {
bzod::cli::doctor::run(data_dir, config).await?; 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, queue_size,
memory_usage, memory_usage,
uptime, uptime,
version: "0.1.0", version: crate::build_info::APP_VERSION,
git_commit: "unknown", git_commit: crate::build_info::GIT_COMMIT,
urls, urls,
}; };
@@ -825,8 +825,8 @@ pub async fn user_status_get(State(state): State<AppState>, jar: CookieJar) -> R
queue_size, queue_size,
memory_usage, memory_usage,
uptime, uptime,
version: "0.1.0", version: crate::build_info::APP_VERSION,
git_commit: "unknown", git_commit: crate::build_info::GIT_COMMIT,
urls, 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 mod system;
pub use routes::create_router; pub use routes::create_router;
pub mod images;
+4 -2
View File
@@ -1,5 +1,7 @@
use crate::state::AppState; 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::{ use axum::{
routing::{delete, get, post, put}, routing::{delete, get, post, put},
Router, Router,
@@ -95,7 +97,7 @@ pub fn create_router(state: AppState) -> Router {
.route("/admin/pages/delete/:id", post(admin::pages_delete)) .route("/admin/pages/delete/:id", post(admin::pages_delete))
.route("/admin/analytics/url/:id", get(admin::url_analytics_get)) .route("/admin/analytics/url/:id", get(admin::url_analytics_get))
.route("/deploy.sh", get(pages::deploy_script)) .route("/deploy.sh", get(pages::deploy_script))
.route("/images/preview.png", get(pages::social_preview)) .route("/images/*path", get(images::image_handler))
.route( .route(
"/admin/analytics/url/:id/export/csv", "/admin/analytics/url/:id/export/csv",
get(admin::url_analytics_csv_export), get(admin::url_analytics_csv_export),
+2 -2
View File
@@ -79,8 +79,8 @@ pub async fn status_endpoint(
queue_size: 0, queue_size: 0,
memory_usage: get_memory_usage(), memory_usage: get_memory_usage(),
uptime_seconds: uptime, uptime_seconds: uptime,
version: "0.1.0", version: crate::build_info::APP_VERSION,
git_commit: "unknown", git_commit: crate::build_info::GIT_COMMIT,
}) })
.into_response() .into_response()
} }
+1240 -70
View File
File diff suppressed because it is too large. Load diff
+42 -39
View File
@@ -13,8 +13,7 @@
</div> </div>
{% endif %} {% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;"> <div class="urls-dashboard">
<!-- Create Landing Page Form -->
<div class="card"> <div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;"> <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> <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"> <form action="/admin/pages/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <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"> <div class="form-group">
<label for="title">Page Title *</label> <label for="title">Page Title *</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required> <input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
@@ -36,7 +35,7 @@
</div> </div>
</div> </div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;"> <div class="page-form-grid-3">
<div class="form-group"> <div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label> <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"> <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"> <div class="form-group">
<label for="state">Publish State</label> <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="draft">Draft</option>
<option value="published" selected>Published</option> <option value="published" selected>Published</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
@@ -59,16 +58,15 @@
<div class="form-group"> <div class="form-group">
<label for="html_content">Raw HTML Document *</label> <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> </div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button> <button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
</form> </form>
</div> </div>
<!-- Landing Pages Table --> <div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="card" style="padding: 0; overflow: hidden;"> <div class="registered-links-header">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;"> <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> <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 Registered Pages
@@ -76,69 +74,75 @@
</div> </div>
<div class="table-container"> <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> <thead>
<tr> <tr>
<th>Page Title</th> <th>Page</th>
<th>Short Path</th> <th>Paths</th>
<th>SEO Preview Path</th>
<th>Status</th> <th>Status</th>
<th>Analytics</th>
<th>QR Code</th> <th>QR Code</th>
<th>Created</th> <th>Actions</th>
<th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if pages.is_empty() %} {% if pages.is_empty() %}
<tr> <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! No landing pages registered. Create one to get started!
</td> </td>
</tr> </tr>
{% else %} {% else %}
{% for page in pages %} {% for page in pages %}
<tr> <tr>
<td> <td data-label="Page">
<div style="font-weight: 700; color: var(--text-primary);"> <div class="page-title-block">
{{ page.title }} <div class="page-title-heading">{{ page.title }}</div>
</div> <div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;"> <div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
UUID: {{ page.id[0..8] }}...
</div> </div>
</td> </td>
<td> <td data-label="Paths">
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;"> <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 }} /p/{{ page.code }}
</a> </a>
</td> </div>
<td> <div class="page-path-group">
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;"> <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 }} /p/{{ page.code }}/{{ page.slug }}
</a> </a>
</div>
</div>
</td> </td>
<td> <td data-label="Status" class="status-cell">
<span class="badge badge-{{ page.state }}"> <span class="badge badge-{{ page.state }}">
{{ page.state }} {{ page.state }}
</span> </span>
</td> </td>
<td> {% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<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;"> <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 📊 Analytics
</a> </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?');"> <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 }}"> <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;"> <button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
Delete Delete
</button> </button>
</form> </form>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -147,8 +151,7 @@
</table> </table>
</div> </div>
<!-- Pagination Controls --> <div class="pagination">
<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);">
{% if current_page > 1 %} {% 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=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> <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>
+54 -37
View File
@@ -4,6 +4,14 @@
{% block active_urls %}active{% endblock %} {% 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 header_title %}Short URL Registry{% endblock %}
{% block content %} {% block content %}
@@ -13,9 +21,8 @@
</div> </div>
{% endif %} {% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;"> <div class="urls-dashboard">
<!-- Create Link Form --> <div class="card url-form-card">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;"> <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> <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 Shorten a New URL
@@ -97,9 +104,8 @@
</form> </form>
</div> </div>
<!-- Links Table --> <div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="card" style="padding: 0; overflow: hidden;"> <div class="registered-links-header">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;"> <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> <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 Registered Links
@@ -113,7 +119,15 @@
</div> </div>
<div class="table-container"> <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> <thead>
<tr> <tr>
<th>Short Link</th> <th>Short Link</th>
@@ -121,51 +135,53 @@
<th>QR Code</th> <th>QR Code</th>
<th>Health</th> <th>Health</th>
<th>Tags</th> <th>Tags</th>
<th>Analytics</th> <th>Actions</th>
<th>Created</th>
<th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if urls.is_empty() %} {% if urls.is_empty() %}
<tr> <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! No shortened URLs registered. Create one to get started!
</td> </td>
</tr> </tr>
{% else %} {% else %}
{% for url in urls %} {% for url in urls %}
<tr> <tr>
<td> <td data-label="Short Link">
<div style="display: flex; align-items: center; gap: 0.25rem;"> <div class="short-link-wrap">
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;"> <div class="short-link-primary">
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }} {{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
</a> </a>
{% if url.is_password_protected() %} {% if url.is_password_protected() %}
<span title="Password Protected" style="color: #fbbf24; display: inline-flex; align-items: center;"> <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> <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> </span>
{% endif %} {% endif %}
</div> </div>
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem; font-weight: 500;"> {% if let Some(title) = url.title.as_deref() %}
{{ url.title.as_deref().unwrap_or("") }} {% 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> </div>
</td> </td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> <td data-label="Destination">
<a href="{{ url.destination }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem;" title="{{ url.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 }} {{ url.destination }}
</a> </a>
{% if let Some(desc) = url.description.as_deref() %} {% if let Some(desc) = url.description.as_deref() %}
{% if !desc.is_empty() %} {% if !desc.is_empty() %}
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;"> <div class="destination-description" title="{{ desc }}">{{ desc }}</div>
{{ desc }}
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if let Some(expires_at) = url.expires_at.as_deref() %} {% if let Some(expires_at) = url.expires_at.as_deref() %}
{% if !expires_at.is_empty() %} {% 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;"> <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> <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] }} Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
{% if url.expired %} {% if url.expired %}
@@ -176,7 +192,7 @@
{% endif %} {% endif %}
{% if let Some(max) = url.max_access_count %} {% 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;"> <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> <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 }} Clicks: {{ url.access_count }} / {{ max }}
{% if url.is_access_exhausted() %} {% if url.is_access_exhausted() %}
@@ -185,43 +201,45 @@
</div> </div>
{% else %} {% else %}
{% if url.access_count > 0 %} {% if url.access_count > 0 %}
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;"> <div class="destination-meta neutral">
Clicks: {{ url.access_count }} Clicks: {{ url.access_count }}
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div>
</td> </td>
{% let code = url.code.as_str() %} {% let code = url.code.as_str() %}
{% include "components/qr_preview.html" %} {% include "components/qr_preview.html" %}
<td> <td data-label="Health" class="status-cell">
<span class="badge badge-{{ url.status }}"> <span class="badge badge-{{ url.status }}">
{{ url.status }} {{ url.status }}
</span> </span>
</td> </td>
<td> <td data-label="Tags">
<div style="display: flex; gap: 0.25rem; flex-wrap: wrap; max-width: 150px;"> {% if url.tags.is_empty() %}
<span class="tags-empty">—</span>
{% else %}
<div class="tags-wrap">
{% for t in url.tags %} {% 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);"> <a href="/admin/urls?tag={{ t }}" class="badge tag-badge">
{{ t }} {{ t }}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
</td> </td>
<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;"> <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 📊 Analytics
</a> </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?');"> <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 }}"> <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;"> <button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
Delete Delete
</button> </button>
</form> </form>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -230,8 +248,7 @@
</table> </table>
</div> </div>
<!-- Pagination Controls --> <div class="pagination">
<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);">
{% if current_page > 1 %} {% 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=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> <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>
+44 -34
View File
@@ -13,7 +13,7 @@
</div> </div>
{% endif %} {% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;"> <div class="urls-dashboard">
<div class="card"> <div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;"> <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> <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,6 +23,7 @@
<form action="/user/pages/create" method="POST"> <form action="/user/pages/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="page-form-grid-2">
<div class="form-group"> <div class="form-group">
<label for="title">Page Title *</label> <label for="title">Page Title *</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required> <input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
@@ -31,6 +32,8 @@
<label for="slug">SEO Slug *</label> <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"> <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>
<div class="page-form-grid-3">
<div class="form-group"> <div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label> <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"> <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">
@@ -47,16 +50,17 @@
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
</div> </div>
</div>
<div class="form-group"> <div class="form-group">
<label for="html_content">HTML Content *</label> <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> </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> </form>
</div> </div>
<div class="card" style="padding: 0; overflow: hidden;"> <div class="card registered-links-shell" 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="registered-links-header">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;"> <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> <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 My Landing Pages
@@ -64,69 +68,75 @@
</div> </div>
<div class="table-container"> <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> <thead>
<tr> <tr>
<th>Page Title</th> <th>Page</th>
<th>Short Path</th> <th>Paths</th>
<th>SEO Preview Path</th>
<th>Status</th> <th>Status</th>
<th>Analytics</th>
<th>QR Code</th> <th>QR Code</th>
<th>Created</th> <th>Actions</th>
<th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if pages.is_empty() %} {% if pages.is_empty() %}
<tr> <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. No landing pages created yet.
</td> </td>
</tr> </tr>
{% else %} {% else %}
{% for page in pages %} {% for page in pages %}
<tr> <tr>
<td> <td data-label="Page">
<div style="font-weight: 700; color: var(--text-primary);"> <div class="page-title-block">
{{ page.title }} <div class="page-title-heading">{{ page.title }}</div>
</div> <div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;"> <div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
UUID: {{ page.id[0..8] }}...
</div> </div>
</td> </td>
<td> <td data-label="Paths">
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;"> <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 }} /p/{{ page.code }}
</a> </a>
</td> </div>
<td> <div class="page-path-group">
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;"> <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 }} /p/{{ page.code }}/{{ page.slug }}
</a> </a>
</div>
</div>
</td> </td>
<td> <td data-label="Status" class="status-cell">
<span class="badge badge-{{ page.state }}"> <span class="badge badge-{{ page.state }}">
{{ page.state }} {{ page.state }}
</span> </span>
</td> </td>
<td> {% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<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;"> <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 📊 Analytics
</a> </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?');"> <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 }}"> <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;"> <button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
Delete Delete
</button> </button>
</form> </form>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -135,7 +145,7 @@
</table> </table>
</div> </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 %} {% 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=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> <a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
+114 -25
View File
@@ -13,8 +13,8 @@
</div> </div>
{% endif %} {% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;"> <div class="urls-dashboard">
<div class="card"> <div class="card url-form-card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;"> <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> <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 Create a New URL
@@ -47,20 +47,34 @@
<label for="tags">Tags (comma-separated, optional)</label> <label for="tags">Tags (comma-separated, optional)</label>
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal"> <input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
</div> </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> </form>
</div> </div>
<div class="card" style="padding: 0; overflow: hidden;"> <div class="card registered-links-shell" 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="registered-links-header">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;"> <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> <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 My Short Links
</h3> </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>
<div class="table-container"> <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> <thead>
<tr> <tr>
<th>Short Link</th> <th>Short Link</th>
@@ -68,36 +82,111 @@
<th>QR Code</th> <th>QR Code</th>
<th>Health</th> <th>Health</th>
<th>Tags</th> <th>Tags</th>
<th>Analytics</th> <th>Actions</th>
<th>Created</th>
<th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if urls.is_empty() %} {% if urls.is_empty() %}
<tr> <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. No short URLs created yet.
</td> </td>
</tr> </tr>
{% else %} {% else %}
{% for url in urls %} {% for url in urls %}
<tr> <tr>
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td> <td data-label="Short Link">
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td> <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() %} {% let code = url.code.as_str() %}
{% include "components/qr_preview.html" %} {% include "components/qr_preview.html" %}
<td>{{ url.status }}</td> <td data-label="Health" class="status-cell">
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td> <span class="badge badge-{{ url.status }}">
<td> {{ url.status }}
<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> </span>
</td> </td>
<td>{{ url.created_at[0..10] }}</td> <td data-label="Tags">
<td> {% 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?');"> <form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem;">Delete</button> <button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.75rem;">
Delete
</button>
</form> </form>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -106,10 +195,10 @@
</table> </table>
</div> </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 %} {% 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=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 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</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 %} {% 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;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt; Prev</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) %} {% if self.is_current(p) %}
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span> <span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
{% else %} {% 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 %} {% endif %}
{% endfor %} {% endfor %}
{% if current_page < total_pages %} {% 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={{ 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 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last &gt;&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 %} {% 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;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last &gt;&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);
}