Compare commits
5
Commits
f49698bb5c
...
v0.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
763a17f8dc | ||
|
|
8e0bcbe580 | ||
|
|
25577b4b83 | ||
|
|
2cd3c2d965 | ||
|
|
b66703082f |
No files matched your search
+3
-3
@@ -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
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ data/
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
|
bzod.env
|
||||||
Generated
+1
-1
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -6,7 +6,7 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
[](https://github.com/thakares/bzod)
|
[](https://github.com/thakares/bzod)
|
||||||
[](https://codeberg.org/thakares/bzod)
|
[](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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=.git/refs");
|
||||||
|
|
||||||
|
if let Ok(output) = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short=12", "HEAD"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
if output.status.success() {
|
||||||
|
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !commit.is_empty() {
|
||||||
|
println!("cargo:rustc-env=BZOD_GIT_COMMIT={commit}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,251 +1,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}"
|
||||||
exit 1
|
}
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. Install Base Dependencies
|
error() {
|
||||||
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
|
echo -e "${RED}$*${NC}" >&2
|
||||||
apt-get update -qq
|
}
|
||||||
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
|
|
||||||
|
|
||||||
# 2. Install Binary (safe atomic download)
|
die() {
|
||||||
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
|
error "$*"
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
REPO="thakares/nx9-url-shortener"
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Atomic replace with backup
|
|
||||||
if [ -f "${INSTALL_PATH}" ]; then
|
|
||||||
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
if [ ! -x "${INSTALL_PATH}" ]; then
|
|
||||||
echo -e "${RED}Binary installation failed${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || {
|
|
||||||
echo -e "${RED}Binary verification failed${NC}"
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify -V also works
|
# ============================================================
|
||||||
"${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || {
|
# Root check
|
||||||
echo -e "${RED}Binary -V verification failed${NC}"
|
# ============================================================
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Show installed version and verify it matches requested version
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
die "This script must be run as root. Use: sudo bash deploy.sh"
|
||||||
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
|
|
||||||
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
|
fi
|
||||||
|
|
||||||
# 4. Setup Directories
|
echo
|
||||||
echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
|
echo -e "${BLUE}============================================================${NC}"
|
||||||
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
|
echo -e "${BLUE} BZOD — Production Docker Deployment${NC}"
|
||||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
|
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 install -y \
|
||||||
|
ca-certificates \
|
||||||
|
curl
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
. /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
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
systemctl enable --now docker
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
|
die "Docker Compose plugin is unavailable."
|
||||||
|
fi
|
||||||
|
|
||||||
|
success "✓ Docker and Docker Compose available"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. Create persistent directories
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
info "[2/8] Creating persistent application directories..."
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"${DATA_DIR}" \
|
||||||
|
"${CONFIG_DIR}" \
|
||||||
|
"${IMAGES_DIR}" \
|
||||||
|
"${COMPOSE_DIR}" \
|
||||||
|
"${BACKUP_ROOT}"
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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}"
|
|
||||||
fi
|
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
|
success "✓ Docker Compose configuration written"
|
||||||
sleep 3
|
|
||||||
|
# ============================================================
|
||||||
|
# 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
|
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}"
|
|
||||||
else
|
HEALTH_OK=0
|
||||||
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
|
|
||||||
|
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
|
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
|
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
@@ -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
|
||||||
|
|||||||
Executable
+9
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Administrator Guide
|
# BZOD Administrator Guide
|
||||||
|
|
||||||
Version: v0.6.0
|
Version: v0.7.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Architecture Guide
|
# BZOD Architecture Guide
|
||||||
|
|
||||||
Version: v0.6.0
|
Version: v0.7.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Multi-User Architecture Guide
|
# BZOD Multi-User Architecture Guide
|
||||||
|
|
||||||
Version: v0.6.0
|
Version: v0.7.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,49 @@
|
|||||||
|
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||||
|
|
||||||
|
Release Date: 2026-08-11
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Responsive UI**: Admin and user registry panels (URLs and landing pages) now adapt across desktop, laptop, tablet, and mobile viewports. Tables switch to card layouts on smaller screens, and horizontal scrolling issues in registry panels have been resolved.
|
||||||
|
|
||||||
|
- **Dark/Light Theme Support**: A dark/light theme toggle has been implemented with theme persistence across sessions and responsive behavior across device sizes.
|
||||||
|
|
||||||
|
- **Build Metadata**: The application now exposes its actual Cargo package version and, when available, the Git commit hash. This is powered by `build.rs` (compile-time Git commit extraction) and `src/build_info.rs` (exposing `APP_VERSION` and `GIT_COMMIT` constants). The version is derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`, and the Git commit hash falls back gracefully to `"unknown"` when unavailable.
|
||||||
|
|
||||||
|
- **Public Landing Page Serving**: Root `/` now serves the public `www/index.html` with runtime file detection and embedded fallback behavior. Public/runtime www assets are supported by the deployment layout.
|
||||||
|
|
||||||
|
## User-Visible Changes
|
||||||
|
|
||||||
|
- Admin URL registry panel is responsive across all device sizes
|
||||||
|
- Admin landing page registry panel is responsive across all device sizes
|
||||||
|
- User URL registry panel is responsive across all device sizes
|
||||||
|
- User landing page registry panel is responsive across all device sizes
|
||||||
|
- Dark/light theme toggle available in the UI
|
||||||
|
- Theme preference persists across sessions
|
||||||
|
- Registry tables switch to card layouts on tablet and mobile viewports
|
||||||
|
- Horizontal scrolling eliminated from registry panels
|
||||||
|
|
||||||
|
## Technical Changes
|
||||||
|
|
||||||
|
- Introduced `build.rs` build script for compile-time metadata extraction
|
||||||
|
- Introduced `src/build_info.rs` module with `APP_VERSION` and `GIT_COMMIT` constants
|
||||||
|
- Version and Git commit now available to system status and admin settings endpoints
|
||||||
|
- Root `/` serves `www/index.html` with runtime/embedded fallback
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Upgrade Notes
|
||||||
|
|
||||||
|
- Direct upgrade from v0.6.0 with no migration required
|
||||||
|
- No database schema changes
|
||||||
|
- No API changes
|
||||||
|
- No configuration changes
|
||||||
|
- No breaking changes to existing functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||||
|
|
||||||
Release Date: 2026-08-09
|
Release Date: 2026-08-09
|
||||||
|
|||||||
+4
-4
@@ -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
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
pub const GIT_COMMIT: &str = match option_env!("BZOD_GIT_COMMIT") {
|
||||||
|
Some(commit) => commit,
|
||||||
|
None => "unknown",
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use crate::auth::hash_password;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::db::Db;
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
data_dir: Option<String>,
|
||||||
|
mut config: Config,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if let Some(d) = data_dir {
|
||||||
|
config.data_dir = PathBuf::from(d);
|
||||||
|
}
|
||||||
|
let db = Db::init(&config)?;
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
|
||||||
|
let admin_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if admin_count > 0 {
|
||||||
|
info!("Administrator already exists; initialization skipped.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = match env::var("ADMIN_USERNAME") {
|
||||||
|
Ok(u) if !u.trim().is_empty() => u.trim().to_string(),
|
||||||
|
_ => {
|
||||||
|
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
|
||||||
|
error!("{}", msg);
|
||||||
|
return Err(msg.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = match env::var("ADMIN_PASSWORD") {
|
||||||
|
Ok(p) if !p.trim().is_empty() => p.trim().to_string(),
|
||||||
|
_ => {
|
||||||
|
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
|
||||||
|
error!("{}", msg);
|
||||||
|
return Err(msg.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||||
|
let u = crate::db::users::create_admin_user(&conn, &username, &hash)?;
|
||||||
|
db.init_user_databases(u.id)?;
|
||||||
|
info!("Administrator initialized successfully.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod disable_user;
|
|||||||
pub mod doctor;
|
pub mod 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,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;
|
||||||
|
|||||||
@@ -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?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::Path,
|
||||||
|
http::{header, HeaderValue, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use std::path::{Component, PathBuf};
|
||||||
|
|
||||||
|
const IMAGES_DIR: &str = "/app/images";
|
||||||
|
|
||||||
|
pub async fn image_handler(Path(path): Path<String>) -> Response {
|
||||||
|
// preview.png is reserved for social-media metadata.
|
||||||
|
// It is served from /app/www/images/preview.png,
|
||||||
|
// not from the persistent application image directory.
|
||||||
|
if path == "preview.png" {
|
||||||
|
return crate::web::pages::social_preview().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let relative = PathBuf::from(&path);
|
||||||
|
|
||||||
|
// Prevent path traversal.
|
||||||
|
if relative.components().any(|component| {
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid image path").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_path = PathBuf::from(IMAGES_DIR).join(relative);
|
||||||
|
|
||||||
|
let content = match tokio::fs::read(&image_path).await {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(_) => {
|
||||||
|
return (StatusCode::NOT_FOUND, "Image not found").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_type = match image_path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.map(|ext| ext.to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
Some("avif") => "image/avif",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_type = HeaderValue::from_static(content_type);
|
||||||
|
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static("public, max-age=86400"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
@@ -11,3 +11,4 @@ pub mod routes;
|
|||||||
pub mod system;
|
pub mod system;
|
||||||
|
|
||||||
pub use routes::create_router;
|
pub use routes::create_router;
|
||||||
|
pub mod images;
|
||||||
+4
-2
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+42
-39
@@ -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> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||||
</div>
|
</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;"><< First</a>
|
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||||
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||||
|
|||||||
+54
-37
@@ -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;"><< 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;"><< First</a>
|
||||||
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||||
|
|||||||
+44
-34
@@ -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> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||||
</div>
|
</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;"><< First</a>
|
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||||
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||||
|
|||||||
+114
-25
@@ -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;">×</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;"><< 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;"><< First</a>
|
||||||
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
<a href="/user/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< Prev</span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< Prev</span>
|
||||||
@@ -119,13 +208,13 @@
|
|||||||
{% if self.is_current(p) %}
|
{% 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 ></a>
|
<a href="/user/urls?page={{ current_page + 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||||
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
<a href="/user/urls?page={{ total_pages }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.base_url = Some("http://bzo.in".to_string());
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_init_admin() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_init_admin_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
|
||||||
|
// Case C/D - missing env vars
|
||||||
|
env::remove_var("ADMIN_USERNAME");
|
||||||
|
env::remove_var("ADMIN_PASSWORD");
|
||||||
|
let res = bzod::cli::init_admin::run(None, config.clone()).await;
|
||||||
|
assert!(res.is_err(), "Should fail without env vars");
|
||||||
|
|
||||||
|
// Case A - No admin + valid ENV
|
||||||
|
env::set_var("ADMIN_USERNAME", "admin");
|
||||||
|
env::set_var("ADMIN_PASSWORD", "securepass");
|
||||||
|
let res = bzod::cli::init_admin::run(None, config.clone()).await;
|
||||||
|
assert!(res.is_ok(), "Should succeed with valid env vars");
|
||||||
|
|
||||||
|
let db = Db::init(&config).unwrap();
|
||||||
|
{
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
let user = bzod::db::users::get_user_by_username(&conn, "admin")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(user.account_type, "admin");
|
||||||
|
assert!(bzod::auth::verify_password(
|
||||||
|
"securepass",
|
||||||
|
&user.password_hash
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case B/F - Admin already exists
|
||||||
|
env::remove_var("ADMIN_USERNAME");
|
||||||
|
env::remove_var("ADMIN_PASSWORD");
|
||||||
|
let res = bzod::cli::init_admin::run(None, config.clone()).await;
|
||||||
|
assert!(
|
||||||
|
res.is_ok(),
|
||||||
|
"Should skip and succeed if admin exists even without env vars"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user