From f49698bb5c9e94de44691ca34cf41f829bf75844 Mon Sep 17 00:00:00 2001 From: Sunil Thakare Date: Sun, 9 Aug 2026 17:17:57 +0530 Subject: [PATCH] Release v0.6.0 --- .gitignore | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 33 +- deploy.sh | 39 +- docker-compose.yml | 2 +- docs/ADMIN_GUIDE.md | 2 +- docs/ARCHITECTURE.md | 27 +- docs/BACKUP_RESTORE.md | 2 +- docs/CHANGELOG.md | 71 + docs/CLI.md | 2 +- docs/COMPARISON.md | 2 +- docs/DATABASES.md | 2 +- docs/INSTALL.md | 6 +- docs/MULTI_USER.md | 2 +- docs/RELEASE-NOTES.md | 129 + docs/SECURITY.md | 31 +- docs/TESTING.md | 32 + docs/UPGRADE.md | 2 +- src/analytics/queue.rs | 12 +- src/analytics/worker.rs | 11 +- src/cli/audit_destinations.rs | 33 + src/cli/mod.rs | 7 + src/cli/restore.rs | 322 +- src/cli/serve.rs | 116 +- src/jobs/aggregate.rs | 14 +- src/jobs/backup.rs | 14 +- src/jobs/expiry.rs | 58 +- src/jobs/healthcheck.rs | 15 +- src/jobs/quota_reconcile.rs | 14 +- src/jobs/retention.rs | 14 +- src/main.rs | 3 + src/services/backup_layout.rs | 121 + src/services/bulk_urls.rs | 204 + src/services/destination_audit.rs | 261 + src/services/mod.rs | 5 + src/services/shortener.rs | 15 +- src/services/slug_transfer.rs | 243 + src/services/urls.rs | 117 + src/state.rs | 14 +- src/utils/db_lock.rs | 61 + src/utils/mod.rs | 4 +- src/utils/network.rs | 116 + src/utils/validation.rs | 141 + src/web/admin.rs | 7065 -------------------------- src/web/admin/analytics.rs | 1007 ++++ src/web/admin/api_keys.rs | 236 + src/web/admin/audit.rs | 126 + src/web/admin/auth.rs | 517 ++ src/web/admin/backups.rs | 300 ++ src/web/admin/dashboard.rs | 170 + src/web/admin/health.rs | 169 + src/web/admin/mod.rs | 849 ++++ src/web/admin/moderation.rs | 639 +++ src/web/admin/pages.rs | 484 ++ src/web/admin/quotas.rs | 117 + src/web/admin/sessions.rs | 114 + src/web/admin/settings.rs | 1043 ++++ src/web/admin/urls.rs | 639 +++ src/web/admin/users.rs | 704 +++ src/web/api.rs | 52 +- src/web/bulk.rs | 262 +- src/web/password_gate.rs | 4 +- src/web/redirect.rs | 465 +- tests/admin_user_management_tests.rs | 4 +- tests/analytics_parity_tests.rs | 4 +- tests/auth_migration_tests.rs | 4 +- tests/business_workflow_tests.rs | 4 +- tests/http_e2e_tests.rs | 9 +- tests/legacy_restore_tests.rs | 632 +++ tests/ownership_tests.rs | 4 +- tests/qr_endpoint_tests.rs | 4 +- tests/redirect_security_tests.rs | 743 +++ tests/routing_tests.rs | 8 +- tests/upgrade_validation_tests.rs | 4 +- 75 files changed, 11199 insertions(+), 7508 deletions(-) create mode 100644 src/cli/audit_destinations.rs create mode 100644 src/services/backup_layout.rs create mode 100644 src/services/bulk_urls.rs create mode 100644 src/services/destination_audit.rs create mode 100644 src/services/slug_transfer.rs create mode 100644 src/services/urls.rs create mode 100644 src/utils/db_lock.rs delete mode 100644 src/web/admin.rs create mode 100644 src/web/admin/analytics.rs create mode 100644 src/web/admin/api_keys.rs create mode 100644 src/web/admin/audit.rs create mode 100644 src/web/admin/auth.rs create mode 100644 src/web/admin/backups.rs create mode 100644 src/web/admin/dashboard.rs create mode 100644 src/web/admin/health.rs create mode 100644 src/web/admin/mod.rs create mode 100644 src/web/admin/moderation.rs create mode 100644 src/web/admin/pages.rs create mode 100644 src/web/admin/quotas.rs create mode 100644 src/web/admin/sessions.rs create mode 100644 src/web/admin/settings.rs create mode 100644 src/web/admin/urls.rs create mode 100644 src/web/admin/users.rs create mode 100644 tests/legacy_restore_tests.rs create mode 100644 tests/redirect_security_tests.rs diff --git a/.gitignore b/.gitignore index 99053da..0295812 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/target +/target/ data/ *.db *.db-wal diff --git a/Cargo.lock b/Cargo.lock index 2f263e6..55f62c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzod" -version = "0.5.3" +version = "0.6.0" dependencies = [ "argon2", "askama", diff --git a/Cargo.toml b/Cargo.toml index 3b162bb..b581337 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bzod" description = "Self-hosted multi-user URL management, landing page and QR analytics platform" -version = "0.5.3" +version = "0.6.0" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/thakares/nx9-url-shortener" diff --git a/README.md b/README.md index 19ba080..7f948a5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue) ![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey) ![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green) -![Version](https://img.shields.io/badge/Version-v0.5.3-purple) +![Version](https://img.shields.io/badge/Version-v0.6.0-purple) [![GitHub](https://img.shields.io/badge/GitHub-thakares%2Fbzod-181717?logo=github)](https://github.com/thakares/bzod) [![Codeberg](https://img.shields.io/badge/Codeberg-thakares%2Fbzod-2185D0?logo=codeberg)](https://codeberg.org/thakares/bzod) @@ -90,7 +90,7 @@ No recurring subscription fees. --- -## Runtime Efficiency (v0.5.3) +## Runtime Efficiency (v0.6.0) | Metric | Value | |---------|------:| @@ -945,6 +945,33 @@ src/ ├── templates/ ├── utils/ ├── web/ +│ ├── admin/ +│ │ ├── analytics.rs +│ │ ├── api_keys.rs +│ │ ├── audit.rs +│ │ ├── auth.rs +│ │ ├── backups.rs +│ │ ├── dashboard.rs +│ │ ├── health.rs +│ │ ├── moderation.rs +│ │ ├── mod.rs +│ │ ├── pages.rs +│ │ ├── quotas.rs +│ │ ├── sessions.rs +│ │ ├── settings.rs +│ │ ├── urls.rs +│ │ └── users.rs +│ ├── api.rs +│ ├── bulk.rs +│ ├── middleware.rs +│ ├── mod.rs +│ ├── multi_user.rs +│ ├── pages.rs +│ ├── password_gate.rs +│ ├── qr.rs +│ ├── redirect.rs +│ ├── routes.rs +│ └── system.rs templates/ @@ -1108,6 +1135,8 @@ Highlights include: - Upgrade Validation - Backup Manifest - Transaction-safe Maintenance +- Modular Admin Architecture (v0.6.0) +- Redirect Handler Hardening (v0.6.0) --- diff --git a/deploy.sh b/deploy.sh index ea12979..c36a3ed 100755 --- a/deploy.sh +++ b/deploy.sh @@ -4,6 +4,8 @@ set -euo pipefail +BZOD_VERSION="0.6.0" + SERVICE_USER="bzod" INSTALL_PATH="/usr/local/bin/bzod" CONFIG_DIR="/etc/bzod" @@ -48,7 +50,7 @@ case $ARCH in esac REPO="thakares/nx9-url-shortener" -RELEASE_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}" +RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${BINARY_NAME}" TMP_BINARY=$(mktemp) @@ -93,13 +95,24 @@ if [ ! -x "${INSTALL_PATH}" ]; then exit 1 fi -"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || { +"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || { echo -e "${RED}Binary verification failed${NC}" exit 1 } -# Show installed version +# Verify -V also works +"${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || { + echo -e "${RED}Binary -V verification failed${NC}" + exit 1 +} + +# Show installed version and verify it matches requested version VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown") +EXPECTED_VERSION="bzod ${BZOD_VERSION}" +if [ "${VERSION}" != "${EXPECTED_VERSION}" ]; then + echo -e "${RED}Version mismatch: expected '${EXPECTED_VERSION}', got '${VERSION}'${NC}" + exit 1 +fi echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}" # 3. Create System User @@ -175,11 +188,23 @@ systemctl daemon-reload # 7. Initialize & Start echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}" -if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then - runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}" - echo -e "${GREEN}✓ Databases initialized${NC}" -else +# 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 systemctl enable --now bzod diff --git a/docker-compose.yml b/docker-compose.yml index 52567e4..e7b3eaf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: - PORT=8654 - RUST_LOG=info hostname: bzod - image: nx9-url-shortener:v0.5.3 + image: nx9-url-shortener:v0.6.0 ports: - mode: ingress target: 8654 diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index 29451f1..736912c 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -1,6 +1,6 @@ # BZOD Administrator Guide -Version: v0.5.3 +Version: v0.6.0 --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f2f6c56..908a963 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # BZOD Architecture Guide -Version: v0.5.3 +Version: v0.6.0 --- @@ -89,7 +89,22 @@ Responsible for: Major modules: ```text -admin.rs +admin/ (modular feature directory) + auth.rs (authentication and session handling) + dashboard.rs (dashboard rendering) + urls.rs (URL management handlers) + pages.rs (landing page management handlers) + analytics.rs (analytics and export handlers) + settings.rs (settings and configuration handlers) + users.rs (user management handlers) + sessions.rs (session administration) + quotas.rs (quota management) + health.rs (health diagnostics) + backups.rs (backup and restore handlers) + api_keys.rs (API key management) + audit.rs (audit log handlers) + moderation.rs (content moderation handlers) + mod.rs (module exports and shared helpers) api.rs pages.rs redirect.rs @@ -339,9 +354,11 @@ Locate owner database ↓ Resolve URL ↓ +Validate destination + ↓ Record analytics ↓ -302 Redirect +301 Redirect (with safe Location header construction) ``` --- @@ -590,7 +607,7 @@ Coverage includes: * Upgrade validation * Multi-user isolation -v0.5.0 includes more than 90 automated tests. +The project includes comprehensive automated test coverage spanning unit, integration, security, and end-to-end tests. --- @@ -635,7 +652,7 @@ Planned for future releases: # Summary -BZOD v0.5.0 is built around a simple principle: +BZOD is built around a simple principle: > Keep deployment simple, keep data local, keep users isolated, and keep recovery easy. diff --git a/docs/BACKUP_RESTORE.md b/docs/BACKUP_RESTORE.md index c74fb89..4cd8a0d 100644 --- a/docs/BACKUP_RESTORE.md +++ b/docs/BACKUP_RESTORE.md @@ -1,6 +1,6 @@ # Backup & Restore Guide -Version: v0.5.3 +Version: v0.6.0 Applies To: BZOD Multi-User Platform --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 81bf6b4..f133c13 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,77 @@ The format is based on Keep a Changelog and this project follows Semantic Versio --- +# 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 +- **CLI Version Reporting**: Added `--version` / `-V` flags derived from Cargo package metadata +- **Deploy Script**: Removed obsolete `init-db` command; database creation and migration now handled by `bzod serve` +- **Version Verification**: Deploy script now verifies installed binary version matches requested version + +# v0.5.3 — Architecture Refinement & Redirect Hardening + +--- + +## Changed + +### Architecture + +* Eliminated the monolithic `admin.rs` handler file +* Reorganized admin functionality into focused feature modules under `src/web/admin/` +* Separated authentication, dashboard, URLs, pages, analytics, settings, users, sessions, quotas, health, backups, API keys, audit, and moderation into dedicated modules +* Extracted shared authentication and authorization helpers +* Extracted common export and helper functionality + +### Redirect Handling + +* Removed panic-prone `HeaderValue::from_str(...).unwrap()` pattern from the redirect path +* Added destination URL validation (scheme validation, control character rejection) +* Added safe HTTP Location header construction +* Improved database error logging with structured fields +* Reduced unnecessary database mutex lock acquisitions on the redirect hot path +* Removed synchronous expiration writes from the redirect hot path + +--- + +## Improved + +* Database lock scoping across admin handlers +* Error handling consistency and observability +* Handler decomposition for oversized functions +* Reduced duplicated handler logic across admin operations + +--- + +## Verified + +* Root landing page (GET /) confirmed as intentional route serving www/index.html +* Release binary built successfully +* Runtime smoke tests passed (GET /, GET /login, GET /admin/login all return HTTP 200) +* SQLite WAL mode and foreign-key enforcement initialized successfully +* All existing migrations reported as up to date +* Comprehensive automated test suite passed, including: + * Authentication and migration tests + * Redirect security tests + * Root landing page test + * Backup and restore tests + * Business workflow tests + * Security tests + * Slug namespace, registry, and transfer tests + * User management and isolation tests + * WAL recovery tests + * HTTP end-to-end tests + +--- + +## Notes + +* This release is an internal architecture and quality improvement +* No new user-facing features were introduced +* Existing API and route behavior was preserved +* Existing redirect security and tenant isolation behavior was preserved + +--- + # v0.5.1 - General Availability (GA) Release Date: 2026-06-20 diff --git a/docs/CLI.md b/docs/CLI.md index d9af8be..4023f69 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -2,7 +2,7 @@ BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management. -The current command list for BZOD v0.5.3 is: +The current command list for BZOD v0.6.0 is: ```text $ bzod --help diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index f104670..4253d57 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -1,4 +1,4 @@ -# BZOD v0.5.1 vs Self-Hosted URL Management Platforms +# BZOD v0.6.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. diff --git a/docs/DATABASES.md b/docs/DATABASES.md index 8f4bdf7..d84624f 100644 --- a/docs/DATABASES.md +++ b/docs/DATABASES.md @@ -2,7 +2,7 @@ # BZOD Database Architecture -BZOD v0.5.1 uses SQLite exclusively. +BZOD v0.6.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. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d759a1c..9e184be 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,6 +1,6 @@ # BZOD Installation Guide -Version: v0.5.1 +Version: v0.6.0 --- @@ -183,13 +183,13 @@ sudo pacman -S \ Example: ```bash -wget https://example.com/bzod-v0.5.0-linux-amd64.tar.gz +wget https://example.com/bzod-v0.6.0-linux-amd64.tar.gz ``` Extract: ```bash -tar -xzf bzod-v0.5.0-linux-amd64.tar.gz +tar -xzf bzod-v0.6.0-linux-amd64.tar.gz ``` Install: diff --git a/docs/MULTI_USER.md b/docs/MULTI_USER.md index b03ce43..94971c8 100644 --- a/docs/MULTI_USER.md +++ b/docs/MULTI_USER.md @@ -1,6 +1,6 @@ # BZOD Multi-User Architecture Guide -Version: v0.5.1 +Version: v0.6.0 --- diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index b8c0ade..c4d7bfd 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -1,3 +1,132 @@ +# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting + +Release Date: 2026-08-09 + +## Highlights + +- **Legacy Backup Restore Compatibility**: Backups created with the web admin "Download Backup" feature (`legacy_flat_backup` format) can now be correctly restored into the current multi-tenant database architecture. Previously, these restores failed with "no such table: users" because the restore validator ran against the empty legacy `users.db` before layout normalization. + +- **CLI Version Reporting**: `bzod --version` and `bzod -V` now report the application version derived from Cargo.toml package metadata, ensuring the reported version cannot diverge from the build. + +- **Deploy Script Modernization**: Removed the obsolete `init-db` command from the deployment script. Database creation and schema migration are now handled automatically by `bzod serve`. The deploy script now verifies the installed binary version using `--version`. + +## Breaking Changes + +None. + +# BZOD v0.5.3 — Architecture Refinement & Redirect Hardening + +BZOD v0.5.3 is an internal quality and maintainability release focused on architectural refinement, redirect handler hardening, and comprehensive verification. + +No new user-facing features are introduced. Existing API contracts, route behavior, authentication, and tenant isolation are fully preserved. + +--- + +# Highlights + +## Modular Admin Architecture + +The former monolithic admin handler file was eliminated and replaced with a focused module directory at `src/web/admin/`. + +Feature modules: + +* `auth.rs` — authentication and session handling +* `dashboard.rs` — dashboard rendering +* `urls.rs` — URL management handlers +* `pages.rs` — landing page management handlers +* `analytics.rs` — analytics and export handlers +* `settings.rs` — settings and configuration handlers +* `users.rs` — user management handlers +* `sessions.rs` — session administration +* `quotas.rs` — quota management +* `health.rs` — health diagnostics +* `backups.rs` — backup and restore handlers +* `api_keys.rs` — API key management +* `audit.rs` — audit log handlers +* `moderation.rs` — content moderation handlers + +Benefits: + +* Improved code organization and navigability +* Reduced coupling between feature areas +* Improved database lock scoping +* Reduced duplicated handler logic +* Better error handling consistency and observability +* Simplified future extension + +--- + +## Redirect Handler Hardening + +The public redirect path (`GET /:code`) was hardened against invalid HTTP Location header values. + +Changes: + +* Removed the panic-prone `HeaderValue::from_str(...).unwrap()` pattern +* Added destination URL validation (scheme enforcement, control character rejection) +* Added safe Location header construction that handles malformed values gracefully +* Improved database error logging with structured fields +* Reduced unnecessary database mutex lock acquisitions +* Removed synchronous expiration writes from the redirect hot path + +Existing redirect security and tenant isolation behavior was preserved. + +--- + +## Root Landing Page Verification + +* Confirmed `GET /` as an intentional application route serving `www/index.html` +* Resolved a runtime path-resolution issue affecting static landing-page resolution +* Verified `GET /` returns HTTP 200 +* Verified `GET /login` returns HTTP 200 +* Verified `GET /admin/login` returns HTTP 200 + +--- + +# Testing & Validation + +BZOD v0.5.3 passed: + +* Release build (`cargo build --release`) +* Comprehensive automated test suite, including: + * Authentication and migration tests + * Redirect security tests + * Root landing page test + * Backup and restore tests + * Business workflow tests + * Security tests + * Slug namespace, registry, and transfer tests + * User management and isolation tests + * WAL recovery tests + * HTTP end-to-end tests +* Runtime smoke tests against the release binary +* SQLite WAL mode and foreign-key enforcement initialization +* Database migration verification (all migrations up to date) + +--- + +# Compatibility + +* No breaking changes +* No API changes +* No route changes +* No database schema changes +* No configuration changes +* Direct upgrade from v0.5.1 with no migration required + +--- + +# Repository + +* Clean source tree established +* Build artifacts, temporary reports, and IDE metadata removed +* Existing BZOD Git history preserved +* Refactoring baseline merged with existing history + +--- + +--- + # BZOD v0.5.1 — Namespace Integrity & Platform Hardening **Release Date:** 2026-06-20 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fef0cd4..6db5f35 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,6 +1,6 @@ # BZOD Security Guide -Version: v0.5.1 +Version: v0.6.0 --- @@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a * Disaster recovery * Operational simplicity -This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.5.0. +This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.6.0. --- @@ -432,6 +432,27 @@ for: --- +# Redirect Security + +The redirect handler validates destination URLs before constructing HTTP Location headers. + +Protections include: + +* URL scheme validation (only http and https destinations are permitted) +* Control character rejection +* CRLF injection prevention +* Safe Location header construction (no panics on malformed values) + +Invalid redirect destinations return: + +```http +500 Internal Server Error +``` + +with structured server-side logging. Full destination values are not exposed to clients. + +--- + # Audit Logging Security-sensitive actions are logged. @@ -599,7 +620,7 @@ If compromise is suspected: # Security Testing -BZOD v0.5.0 includes tests covering: +BZOD v0.6.0 includes tests covering: * Authentication * Authorization @@ -610,6 +631,8 @@ BZOD v0.5.0 includes tests covering: * Upgrade migrations * Backup integrity * Disaster recovery +* Redirect destination validation +* HTTP Location header safety These tests are executed during CI and release validation. @@ -642,7 +665,7 @@ These may be addressed in future releases. # Summary -BZOD v0.5.0 provides: +BZOD v0.6.0 provides: * Centralized authentication * Secure session management diff --git a/docs/TESTING.md b/docs/TESTING.md index 8f0f0ea..9cae889 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -325,6 +325,38 @@ and: for final landing page render. +Root landing page: + +```text +GET / +``` + +must serve the static landing page. + +Expected: + +```http +200 OK +Content-Type: text/html +``` + +Redirect security: + +Redirect destinations are validated against: + +* Invalid URL schemes +* CRLF injection attempts +* Control character injection +* Malformed HTTP Location header values + +Invalid destinations must return: + +```http +500 Internal Server Error +``` + +and must not panic or produce malformed HTTP responses. + --- # 12. Backup Validation diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 7866c77..bed5fa1 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -1,6 +1,6 @@ # Upgrade Guide -Version: v0.5.1 +Version: v0.6.0 This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1. diff --git a/src/analytics/queue.rs b/src/analytics/queue.rs index 17ef067..d758dc7 100644 --- a/src/analytics/queue.rs +++ b/src/analytics/queue.rs @@ -8,15 +8,19 @@ pub struct AnalyticsQueue { } impl AnalyticsQueue { - pub fn new(db: Db, capacity: usize) -> Self { + pub fn new( + db: Db, + capacity: usize, + shutdown_rx: tokio::sync::watch::Receiver, + ) -> (Self, tokio::task::JoinHandle<()>) { let (sender, receiver) = mpsc::channel(capacity); // Spawn background worker to batch-write records - tokio::spawn(async move { - super::worker::run_worker(db, receiver).await; + let handle = tokio::spawn(async move { + super::worker::run_worker(db, receiver, shutdown_rx).await; }); - Self { sender } + (Self { sender }, handle) } // Attempt to queue a visit. Non-blocking. diff --git a/src/analytics/worker.rs b/src/analytics/worker.rs index 1aeb1b2..d3cdbc2 100644 --- a/src/analytics/worker.rs +++ b/src/analytics/worker.rs @@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch; use crate::db::Db; use crate::models::VisitRecord; -pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver) { +pub async fn run_worker( + db: Db, + mut receiver: mpsc::Receiver, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { let mut batch = Vec::new(); let batch_size = 50; let flush_interval = Duration::from_secs(2); @@ -37,6 +41,11 @@ pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver) { flush_batch(&db, &mut batch); } } + _ = shutdown_rx.changed() => { + info!("Analytics worker flushing pending records"); + flush_batch(&db, &mut batch); + break; + } } } } diff --git a/src/cli/audit_destinations.rs b/src/cli/audit_destinations.rs new file mode 100644 index 0000000..3024912 --- /dev/null +++ b/src/cli/audit_destinations.rs @@ -0,0 +1,33 @@ +use crate::config::Config; +use crate::db::Db; +use crate::services::destination_audit::{audit_all_destinations, format_report}; +use std::path::PathBuf; +use tracing::info; + +/// Read-only audit of all stored redirect destinations. +/// +/// Does not rewrite, delete, or "repair" any records. +pub async fn run( + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { + config.data_dir = PathBuf::from(d); + } + + info!("Starting read-only destination audit..."); + let db = Db::init(&config)?; + let report = audit_all_destinations(&db)?; + print!("{}", format_report(&report)); + + if report.invalid > 0 { + // Non-zero exit so automation can detect findings without treating them as crashes. + Err(format!( + "destination audit found {} invalid stored URL(s)", + report.invalid + ) + .into()) + } else { + Ok(()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c251f18..a30ca92 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,7 @@ use clap::{Parser, Subcommand}; pub mod admin_migrate; +pub mod audit_destinations; pub mod backup; pub mod backup_user; pub mod create_admin; @@ -23,6 +24,7 @@ pub mod validate; #[derive(Parser)] #[command(name = "bzod")] +#[command(version)] #[command(about = "BZOD - Personal Redirector & Landing Page Platform")] pub struct Cli { #[command(subcommand)] @@ -72,6 +74,11 @@ pub enum Commands { #[arg(long)] data_dir: Option, }, + /// Read-only audit of stored redirect destinations (schemes, control chars, malformed) + AuditDestinations { + #[arg(long)] + data_dir: Option, + }, /// Create a new administrator user in the database CreateAdmin { #[arg(long)] diff --git a/src/cli/restore.rs b/src/cli/restore.rs index ecacc9d..aa6b7e3 100644 --- a/src/cli/restore.rs +++ b/src/cli/restore.rs @@ -1,21 +1,250 @@ use crate::config::Config; +use crate::services::registry_validator::RegistryIssueType; use flate2::read::GzDecoder; use std::fs::File; use std::io::{self, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tar::Archive; -use tracing::{error, info}; +use tracing::{error, info, warn}; + +/// Read backup_manifest.json and return true if this is a legacy_flat_backup. +fn is_legacy_flat_backup(temp_dir: &Path) -> bool { + let manifest_path = temp_dir.join("backup_manifest.json"); + if !manifest_path.exists() { + return false; + } + match std::fs::read_to_string(&manifest_path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(val) => val.get("type").and_then(|t| t.as_str()) == Some("legacy_flat_backup"), + Err(_) => false, + }, + Err(_) => false, + } +} + +/// Detect if the unpacked archive is in flat layout (files at root, not in admin/ subdirectory). +fn is_flat_layout(temp_dir: &Path) -> bool { + temp_dir.join("admin.db").exists() && !temp_dir.join("admin").join("admin.db").exists() +} + +/// Bootstrap users.db for a legacy backup where users.db is empty/unmigrated. +/// +/// This function: +/// 1. Runs USERS_MIGRATIONS on users.db to create the required schema. +/// 2. Reads the actual administrator identity from admin.db (preserving +/// the original username and argon2id password hash — no manufacturing). +/// 3. Creates a legacy_admin system placeholder (id=1) for tenant ownership. +/// 4. Creates an admin account with the original credentials. +/// 5. Scans global_slugs for owner_user_ids and creates disabled placeholder +/// accounts for any missing tenants. +fn bootstrap_legacy_users_db(temp_dir: &Path) -> Result<(), Box> { + use crate::db::migrations::{run_migrations, USERS_MIGRATIONS}; + + let users_db_path = temp_dir.join("admin").join("users.db"); + let admin_db_path = temp_dir.join("admin").join("admin.db"); + let system_db_path = temp_dir.join("admin").join("system.db"); + + // Check if users.db already has the users table (i.e., not a legacy backup) + { + let conn = rusqlite::Connection::open(&users_db_path)?; + let has_users_table: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='users');", + [], + |r| r.get(0), + ) + .unwrap_or(false); + if has_users_table { + info!("users.db already has users table; skipping legacy bootstrap"); + return Ok(()); + } + } + + info!("Legacy users.db detected (empty/unmigrated). Bootstrapping current schema..."); + + // Step 1: Run migrations to create the users.db schema + let mut users_conn = rusqlite::Connection::open(&users_db_path)?; + crate::db::sqlite::enable_wal(&users_conn, "users")?; + crate::db::sqlite::enable_foreign_keys(&users_conn, "users")?; + run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None)?; + + // Step 2: Read the actual administrator identity from admin.db + let (admin_username, admin_password_hash) = { + let admin_conn = rusqlite::Connection::open(&admin_db_path)?; + + // The legacy admin.db users table has schema: + // id TEXT PRIMARY KEY (UUID), username TEXT, password_hash TEXT, created_at TEXT + // Read the actual admin — typically the first (and often only) user. + let result: Result<(String, String), _> = admin_conn.query_row( + "SELECT username, password_hash FROM users ORDER BY created_at ASC LIMIT 1;", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ); + + match result { + Ok((username, hash)) => { + info!( + "Preserved administrator identity from legacy admin.db: username='{}'", + username + ); + (username, hash) + } + Err(e) => { + return Err(format!( + "Failed to read administrator credentials from legacy admin.db: {}", + e + ) + .into()); + } + } + }; + + // Step 3: Create legacy_admin system placeholder (id=1) for tenant content ownership + // This account owns the content.db/analytics.db from the flat backup (users/1/). + // It uses the original admin's password hash so no synthetic credentials are introduced. + let now = chrono::Utc::now().to_rfc3339(); + users_conn.execute( + "INSERT INTO users (id, username, password_hash, status, created_at, account_type) + VALUES (?1, ?2, ?3, ?4, ?5, ?6);", + rusqlite::params![ + 1i64, + "legacy_admin", + &admin_password_hash, + "disabled", + &now, + "system" + ], + )?; + users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [1i64])?; + info!("Created legacy_admin system account (id=1) for tenant content ownership"); + + // Step 4: Create the actual admin account with original credentials + users_conn.execute( + "INSERT INTO users (username, password_hash, status, created_at, account_type) + VALUES (?1, ?2, ?3, ?4, ?5);", + rusqlite::params![ + &admin_username, + &admin_password_hash, + "active", + &now, + "admin" + ], + )?; + let admin_id = users_conn.last_insert_rowid(); + users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [admin_id])?; + info!( + "Created admin account '{}' (id={}) with original credentials", + admin_username, admin_id + ); + + // Step 5: Scan global_slugs for owner_user_ids and create placeholders for missing tenants + if system_db_path.exists() { + let system_conn = rusqlite::Connection::open(&system_db_path)?; + let has_global_slugs: bool = system_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');", + [], + |r| r.get(0), + ) + .unwrap_or(false); + + if has_global_slugs { + let mut stmt = + system_conn.prepare("SELECT DISTINCT owner_user_id FROM global_slugs;")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let owner_id: i64 = row.get(0)?; + // Skip user 1 (legacy_admin) and the admin we just created + if owner_id == 1 || owner_id == admin_id { + continue; + } + + // Check if this user already exists in users.db + let exists: bool = users_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", + [owner_id], + |r| r.get(0), + ) + .unwrap_or(false); + + if !exists { + // Create a disabled placeholder so RegistryValidator can resolve ownership. + // The tenant's actual databases were not included in the flat backup. + let placeholder_name = format!("restored_user_{}", owner_id); + users_conn.execute( + "INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", + rusqlite::params![ + owner_id, + &placeholder_name, + &admin_password_hash, + "disabled", + &now, + "standard", + "Placeholder created during legacy_flat_backup restore. Original tenant databases were not included in the flat backup." + ], + )?; + users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [owner_id])?; + warn!( + "Created placeholder account for user_id={} (referenced in global_slugs but tenant databases not in backup)", + owner_id + ); + } + } + } + } + + Ok(()) +} + +/// Classify registry issues into hard errors vs warnings for legacy restore. +/// +/// Hard errors: DuplicateSlug, InvalidTargetType, InvalidStatus +/// Warnings: MissingDatabase, MissingTarget, MissingOwner, StaleReservation, +/// TenantAdminHasIsolatedContent +fn classify_registry_issues( + issues: &[crate::services::registry_validator::RegistryIssue], + is_legacy: bool, +) -> ( + Vec<&crate::services::registry_validator::RegistryIssue>, + Vec<&crate::services::registry_validator::RegistryIssue>, +) { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + for issue in issues { + match issue.issue_type { + RegistryIssueType::DuplicateSlug + | RegistryIssueType::InvalidTargetType + | RegistryIssueType::InvalidStatus => { + errors.push(issue); + } + RegistryIssueType::MissingOwner if !is_legacy => { + errors.push(issue); + } + _ => { + // For legacy restores: MissingDatabase, MissingTarget, MissingOwner, + // StaleReservation, TenantAdminHasIsolatedContent are warnings. + // These represent pre-existing inconsistencies in the backup data, + // not restore corruption. + warnings.push(issue); + } + } + } + + (errors, warnings) +} pub fn perform_restore( - file_path: &std::path::Path, - data_dir: &std::path::Path, + file_path: &Path, + data_dir: &Path, ) -> Result<(), Box> { - // 1. Open the archive + // 1. Open and unpack the archive to a temporary directory let f = File::open(file_path)?; let tar_gz = GzDecoder::new(f); let mut archive = Archive::new(tar_gz); - // 2. Unpack to temporary directory first let temp_dir = std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&temp_dir)?; @@ -25,7 +254,33 @@ pub fn perform_restore( return Err(e.into()); } - // 3. Run validation on temp_dir + // 2. Detect backup format + let is_legacy = is_legacy_flat_backup(&temp_dir); + let needs_normalization = is_flat_layout(&temp_dir); + + if is_legacy { + info!("Detected legacy_flat_backup format — using legacy-aware restore path"); + } + + // 3. Normalize flat layout into multi-tenant structure BEFORE any validation + if needs_normalization { + info!("Normalizing flat database layout into multi-tenant structure..."); + if let Err(e) = crate::services::backup_layout::normalize_restored_layout(&temp_dir) { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!("Failed to normalize legacy layout: {}", e).into()); + } + } + + // 4. For legacy backups: bootstrap the empty users.db with the current schema + // and populate it from admin.db credentials + if is_legacy { + if let Err(e) = bootstrap_legacy_users_db(&temp_dir) { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!("Failed to bootstrap legacy users database: {}", e).into()); + } + } + + // 5. Run validation on the normalized temp_dir let mut temp_config = Config::load(); temp_config.data_dir = temp_dir.clone(); @@ -46,16 +301,8 @@ pub fn perform_restore( } // Registry integrity check - let system_db_path = if temp_dir.join("admin/system.db").exists() { - temp_dir.join("admin/system.db") - } else { - temp_dir.join("system.db") - }; - let users_db_path = if temp_dir.join("admin/users.db").exists() { - temp_dir.join("admin/users.db") - } else { - temp_dir.join("users.db") - }; + let system_db_path = temp_dir.join("admin").join("system.db"); + let users_db_path = temp_dir.join("admin").join("users.db"); if system_db_path.exists() && users_db_path.exists() { let system_conn = rusqlite::Connection::open(&system_db_path)?; @@ -68,12 +315,37 @@ pub fn perform_restore( ) { Ok(issues) => { if !issues.is_empty() { - let _ = std::fs::remove_dir_all(&temp_dir); - return Err(format!( - "Registry integrity errors in backup: {} issues detected", - issues.len() - ) - .into()); + let (hard_errors, warnings) = classify_registry_issues(&issues, is_legacy); + + // Log all warnings + for w in &warnings { + warn!( + "Legacy restore warning: {:?} — {}", + w.issue_type, w.description + ); + } + + // Abort only on hard errors + if !hard_errors.is_empty() { + let descriptions: Vec = hard_errors + .iter() + .map(|e| format!("{:?}: {}", e.issue_type, e.description)) + .collect(); + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!( + "Registry integrity errors in backup ({} critical): {}", + hard_errors.len(), + descriptions.join("; ") + ) + .into()); + } + + if !warnings.is_empty() { + info!( + "Registry validation completed with {} warnings (pre-existing backup inconsistencies)", + warnings.len() + ); + } } } Err(e) => { @@ -83,13 +355,13 @@ pub fn perform_restore( } } - // 4. If validation succeeds, copy temp_dir contents to data_dir + // 6. If validation succeeds, atomically replace data_dir contents if data_dir.exists() { let _ = std::fs::remove_dir_all(data_dir); } std::fs::create_dir_all(data_dir)?; - fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; diff --git a/src/cli/serve.rs b/src/cli/serve.rs index fc35488..24575cf 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -7,6 +7,30 @@ use std::path::PathBuf; use std::time::Instant; use tracing::info; +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} + pub async fn run( host: Option, port: Option, @@ -29,39 +53,63 @@ pub async fn run( // Init DBs let db = Db::init(&config)?; + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let mut join_handles = Vec::new(); + // Init Queue - let queue = AnalyticsQueue::new(db.clone(), 1000); + let (queue, analytics_handle) = AnalyticsQueue::new(db.clone(), 1000, shutdown_rx.clone()); + join_handles.push(("analytics_worker", analytics_handle)); // Spawn background tasks let link_checker_db = db.clone(); let link_checker_interval = config.link_check_interval_mins; - tokio::spawn(async move { - crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await; - }); + let rx = shutdown_rx.clone(); + join_handles.push(( + "link_checker", + tokio::spawn(async move { + crate::jobs::run_link_checker(link_checker_db, link_checker_interval, rx).await; + }), + )); let aggregator_db = db.clone(); let aggregator_interval = config.aggregation_interval_mins; - tokio::spawn(async move { - crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await; - }); + let rx = shutdown_rx.clone(); + join_handles.push(( + "aggregator", + tokio::spawn(async move { + crate::jobs::run_aggregator(aggregator_db, aggregator_interval, rx).await; + }), + )); let retention_db = db.clone(); let retention_days = config.data_retention_days; - tokio::spawn(async move { - crate::jobs::run_retention_cleaner(retention_db, retention_days).await; - }); + let rx = shutdown_rx.clone(); + join_handles.push(( + "retention_cleaner", + tokio::spawn(async move { + crate::jobs::run_retention_cleaner(retention_db, retention_days, rx).await; + }), + )); // Spawn optional backup scheduler let backup_db = db.clone(); let backup_config = config.clone(); - tokio::spawn(async move { - crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await; - }); + let rx = shutdown_rx.clone(); + join_handles.push(( + "backup_scheduler", + tokio::spawn(async move { + crate::jobs::backup::run_backup_scheduler(backup_db, backup_config, rx).await; + }), + )); let expiry_db = db.clone(); - tokio::spawn(async move { - crate::jobs::run_expiry_checker(expiry_db).await; - }); + let rx = shutdown_rx.clone(); + join_handles.push(( + "expiry_checker", + tokio::spawn(async move { + crate::jobs::run_expiry_checker(expiry_db, rx).await; + }), + )); let reconcile_db = db.clone(); let reconcile_interval_hours = { @@ -75,9 +123,13 @@ pub async fn run( .and_then(|val| val.parse::().ok()) .unwrap_or(24) }; - tokio::spawn(async move { - crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await; - }); + let rx = shutdown_rx.clone(); + join_handles.push(( + "quota_reconciliation", + tokio::spawn(async move { + crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours, rx).await; + }), + )); let state = AppState { admin_db: db.admin.clone(), @@ -98,7 +150,31 @@ pub async fn run( let listener = tokio::net::TcpListener::bind(&addr).await?; info!("Listening for requests on http://{}", addr); - axum::serve(listener, router).await?; + + axum::serve(listener, router) + .with_graceful_shutdown(async move { + shutdown_signal().await; + info!("Shutdown signal received"); + info!("Stopping HTTP server..."); + let _ = shutdown_tx.send(true); + }) + .await?; + + info!("Stopping background workers..."); + + let timeout_duration = std::time::Duration::from_secs(10); + let deadline = tokio::time::Instant::now() + timeout_duration; + + for (name, handle) in join_handles { + match tokio::time::timeout_at(deadline, handle).await { + Ok(Ok(_)) => {} + Ok(Err(e)) => tracing::error!("Background task '{}' panicked: {:?}", name, e), + Err(_) => tracing::warn!("Background task did not terminate: {}", name), + } + } + + info!("Background workers stopped"); + info!("BZOD shutdown complete"); Ok(()) } diff --git a/src/jobs/aggregate.rs b/src/jobs/aggregate.rs index 0a47732..1015939 100644 --- a/src/jobs/aggregate.rs +++ b/src/jobs/aggregate.rs @@ -5,9 +5,19 @@ use super::{log_job_end, log_job_start}; use crate::analytics::aggregate_day; use crate::db::Db; -pub async fn run_aggregator(db: Db, interval_mins: u64) { +pub async fn run_aggregator( + db: Db, + interval_mins: u64, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { loop { - tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {} + _ = shutdown_rx.changed() => { + info!("Analytics aggregator shutting down..."); + break; + } + } info!("Running background analytics aggregator..."); let user_ids: Vec = { diff --git a/src/jobs/backup.rs b/src/jobs/backup.rs index ea876ee..2ba5b7c 100644 --- a/src/jobs/backup.rs +++ b/src/jobs/backup.rs @@ -4,7 +4,11 @@ use crate::db::Db; use std::time::Duration; use tracing::{error, info}; -pub async fn run_backup_scheduler(db: Db, config: Config) { +pub async fn run_backup_scheduler( + db: Db, + config: Config, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { if !config.backup_enabled { info!("Background backup scheduler is disabled."); return; @@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) { ); loop { // Run backup every configured interval - tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await; + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)) => {} + _ = shutdown_rx.changed() => { + info!("Backup scheduler shutting down..."); + break; + } + } info!("Running background database backup..."); let job_id = log_job_start(&db.system, "database_backup"); diff --git a/src/jobs/expiry.rs b/src/jobs/expiry.rs index b770689..1228fa8 100644 --- a/src/jobs/expiry.rs +++ b/src/jobs/expiry.rs @@ -1,33 +1,73 @@ use crate::db::Db; use std::time::Duration; -use tracing::info; +use tracing::{error, info, warn}; /// Background job that marks expired URLs. /// /// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0` /// gets flipped to `expired = 1`. -pub async fn run_expiry_checker(db: Db) { +/// +/// Correctness note: the redirect handler treats wall-clock `expires_at` as +/// authoritative and returns 410 without depending on this sweeper. The sweeper +/// is maintenance (persist `expired=1`) and must remain idempotent. +pub async fn run_expiry_checker(db: Db, mut shutdown_rx: tokio::sync::watch::Receiver) { loop { - tokio::time::sleep(Duration::from_secs(60)).await; + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(60)) => {} + _ = shutdown_rx.changed() => { + info!("Expiry checker shutting down..."); + break; + } + } let user_ids: Vec = { - let conn = db.users.lock().unwrap(); + let conn = match db.users.lock() { + Ok(c) => c, + Err(e) => { + error!(error = %e, "expiry job: users_db mutex poisoned"); + continue; + } + }; let mut stmt = match conn.prepare("SELECT id FROM users;") { Ok(s) => s, - Err(_) => continue, + Err(e) => { + error!(error = %e, "expiry job: failed to list users"); + continue; + } }; let rows = match stmt.query_map([], |row| row.get(0)) { Ok(r) => r, - Err(_) => continue, + Err(e) => { + error!(error = %e, "expiry job: failed to map user ids"); + continue; + } }; rows.filter_map(|r| r.ok()).collect() }; let mut total_expired = 0; for user_id in user_ids { - if let Ok(conn) = super::open_user_content_conn(&db, user_id) { - let count = crate::db::content::expire_urls(&conn).unwrap_or(0); - total_expired += count; + match super::open_user_content_conn(&db, user_id) { + Ok(conn) => match crate::db::content::expire_urls(&conn) { + Ok(count) => total_expired += count, + Err(e) => { + warn!( + owner_user_id = user_id, + error = %e, + "expiry job: expire_urls failed" + ); + } + }, + Err(e) => { + // Missing content.db for a user is common; only log open errors that are unexpected. + if !matches!(e, rusqlite::Error::SqliteFailure(_, _)) { + warn!( + owner_user_id = user_id, + error = %e, + "expiry job: could not open content.db" + ); + } + } } } diff --git a/src/jobs/healthcheck.rs b/src/jobs/healthcheck.rs index eb97b55..11c9be7 100644 --- a/src/jobs/healthcheck.rs +++ b/src/jobs/healthcheck.rs @@ -8,7 +8,11 @@ use uuid::Uuid; use super::{log_job_end, log_job_start}; use crate::db::Db; -pub async fn run_link_checker(db: Db, interval_mins: u64) { +pub async fn run_link_checker( + db: Db, + interval_mins: u64, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { let client = Client::builder() .timeout(Duration::from_secs(10)) .user_agent("bzod-link-checker/0.1") @@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) { loop { // Sleep first to give server time to start up - tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {} + _ = shutdown_rx.changed() => { + info!("Link checker shutting down..."); + break; + } + } + info!("Running background link health check..."); let job_id = log_job_start(&db.system, "link_checker"); diff --git a/src/jobs/quota_reconcile.rs b/src/jobs/quota_reconcile.rs index 09ffdcd..f5ee754 100644 --- a/src/jobs/quota_reconcile.rs +++ b/src/jobs/quota_reconcile.rs @@ -2,10 +2,20 @@ use crate::db::Db; use std::time::Duration; use tracing::{error, info}; -pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) { +pub async fn run_quota_reconciliation( + db: Db, + interval_hours: u64, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { loop { // Sleep first - tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await; + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(interval_hours * 3600)) => {} + _ = shutdown_rx.changed() => { + info!("Quota reconciliation shutting down..."); + break; + } + } info!("Running background quota reconciliation..."); let user_ids: Vec = { diff --git a/src/jobs/retention.rs b/src/jobs/retention.rs index 84617b4..873e098 100644 --- a/src/jobs/retention.rs +++ b/src/jobs/retention.rs @@ -4,7 +4,11 @@ use tracing::{error, info}; use super::{log_job_end, log_job_start}; use crate::db::Db; -pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option) { +pub async fn run_retention_cleaner( + db: Db, + retention_days_opt: Option, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { let retention_days = match retention_days_opt { Some(days) => days, None => return, @@ -12,7 +16,13 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option) { loop { // Check once every 24 hours - tokio::time::sleep(Duration::from_secs(24 * 3600)).await; + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(24 * 3600)) => {} + _ = shutdown_rx.changed() => { + info!("Retention cleaner shutting down..."); + break; + } + } info!("Running background data retention cleanup..."); let user_ids: Vec = { diff --git a/src/main.rs b/src/main.rs index e77542a..e3124ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,9 @@ async fn main() -> Result<(), Box> { Commands::Validate { data_dir } => { bzod::cli::validate::run(data_dir, config).await?; } + Commands::AuditDestinations { data_dir } => { + bzod::cli::audit_destinations::run(data_dir, config).await?; + } Commands::CreateAdmin { username, data_dir } => { bzod::cli::create_admin::run(username, data_dir, config).await?; } diff --git a/src/services/backup_layout.rs b/src/services/backup_layout.rs new file mode 100644 index 0000000..be9bcfa --- /dev/null +++ b/src/services/backup_layout.rs @@ -0,0 +1,121 @@ +//! Post-restore filesystem layout normalization for multi-tenant BZOD data dirs. +//! +//! Extracted from admin restore handlers so path moves are testable without HTTP. + +use std::path::{Path, PathBuf}; +use tracing::warn; + +/// Move flat legacy DB files into multi-tenant paths after tarball extract. +/// +/// Layout: +/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/` +/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/` +pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> { + let admin_dir = data_dir.join("admin"); + let users_1_dir = data_dir.join("users").join("1"); + std::fs::create_dir_all(&admin_dir)?; + std::fs::create_dir_all(&users_1_dir)?; + + let admin_files = [ + "admin.db", + "admin.db-wal", + "admin.db-shm", + "system.db", + "system.db-wal", + "system.db-shm", + "users.db", + "users.db-wal", + "users.db-shm", + ]; + for f in admin_files { + let src = data_dir.join(f); + if src.exists() { + let dst = admin_dir.join(f); + if let Err(e) = std::fs::rename(&src, &dst) { + warn!( + file = f, + error = %e, + "failed to move restored admin file into admin/" + ); + return Err(e); + } + } + } + + let content_files = [ + "content.db", + "content.db-wal", + "content.db-shm", + "analytics.db", + "analytics.db-wal", + "analytics.db-shm", + ]; + for f in content_files { + let src = data_dir.join(f); + if src.exists() { + let dst = users_1_dir.join(f); + if let Err(e) = std::fs::rename(&src, &dst) { + warn!( + file = f, + error = %e, + "failed to move restored content file into users/1/" + ); + return Err(e); + } + } + } + + Ok(()) +} + +/// Paths used when reopening connections after restore. +#[derive(Debug, Clone)] +pub struct RestoredDbPaths { + pub admin: PathBuf, + pub system: PathBuf, + pub users: PathBuf, + pub content: PathBuf, + pub analytics: PathBuf, +} + +impl RestoredDbPaths { + pub fn from_data_dir(data_dir: &Path) -> Self { + Self { + admin: data_dir.join("admin/admin.db"), + system: data_dir.join("admin/system.db"), + users: data_dir.join("admin/users.db"), + content: data_dir.join("users/1/content.db"), + analytics: data_dir.join("users/1/analytics.db"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn moves_flat_files_into_tenant_layout() { + let dir = std::env::temp_dir().join(format!("bzod_layout_{}", uuid::Uuid::new_v4())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("admin.db"), b"a").unwrap(); + fs::write(dir.join("system.db"), b"s").unwrap(); + fs::write(dir.join("users.db"), b"u").unwrap(); + fs::write(dir.join("content.db"), b"c").unwrap(); + fs::write(dir.join("analytics.db"), b"an").unwrap(); + + normalize_restored_layout(&dir).unwrap(); + + assert!(dir.join("admin/admin.db").exists()); + assert!(dir.join("admin/system.db").exists()); + assert!(dir.join("admin/users.db").exists()); + assert!(dir.join("users/1/content.db").exists()); + assert!(dir.join("users/1/analytics.db").exists()); + assert!(!dir.join("admin.db").exists()); + assert!(!dir.join("content.db").exists()); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/services/bulk_urls.rs b/src/services/bulk_urls.rs new file mode 100644 index 0000000..af0bd71 --- /dev/null +++ b/src/services/bulk_urls.rs @@ -0,0 +1,204 @@ +//! Bulk URL creation business logic (transaction + slug reservation). +//! +//! Handlers own auth/HTTP; this module owns validation, reservation, and inserts. + +use crate::auth::generate_token; +use crate::auth::password::hash_password; +use crate::models::Url; +use crate::utils::validation::validate_redirect_destination; +use rusqlite::{Connection, Transaction}; +use std::sync::Mutex; + +/// One item in a bulk URL create request (mirrors the HTTP payload shape). +#[derive(Debug, Clone)] +pub struct BulkUrlCreateItem { + pub destination: String, + pub code: Option, + pub title: Option, + pub description: Option, + pub tags: Option>, + pub expires_at: Option, + pub password: Option, + pub max_access_count: Option, +} + +#[derive(Debug)] +pub enum BulkUrlError { + BadRequest(String), + Conflict(String), + Forbidden(String), + Internal(String), +} + +impl BulkUrlError { + pub fn message(&self) -> &str { + match self { + Self::BadRequest(m) | Self::Conflict(m) | Self::Forbidden(m) | Self::Internal(m) => m, + } + } +} + +fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) { + for slug in slugs { + let _ = crate::db::users::release_global_slug(system, slug, owner_user_id); + } +} + +/// Check that the tenant can accept `additional` new URLs. +pub fn ensure_url_quota( + users_db: &Mutex, + user_id: i64, + additional: i64, +) -> Result<(), BulkUrlError> { + let users_conn = crate::utils::lock_db(users_db, "users_db") + .map_err(|e| BulkUrlError::Internal(e.to_string()))?; + match crate::db::users::get_user_quotas(&users_conn, user_id) { + Ok(Some(quotas)) => { + if quotas.current_urls + additional > quotas.max_urls { + Err(BulkUrlError::Forbidden("Quota limit exceeded".into())) + } else { + Ok(()) + } + } + Ok(None) => Err(BulkUrlError::Forbidden("User quota not found".into())), + Err(e) => Err(BulkUrlError::Internal(format!("quota lookup failed: {e}"))), + } +} + +/// Create many URLs inside a single content transaction with global slug reservation. +pub fn create_urls_bulk( + content_db: &Mutex, + system_db: &Mutex, + users_db: &Mutex, + owner_user_id: i64, + items: Vec, +) -> Result, BulkUrlError> { + let mut conn = crate::utils::lock_db(content_db, "content_db") + .map_err(|e| BulkUrlError::Internal(e.to_string()))?; + let tx = conn.transaction().map_err(|e| { + BulkUrlError::Internal(format!("Failed to start database transaction: {e}")) + })?; + + let mut created_urls = Vec::new(); + let mut reserved_slugs: Vec = Vec::new(); + + for item in items { + match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) { + Ok(url) => created_urls.push(url), + Err(e) => { + let _ = tx.rollback(); + if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") { + release_reserved(&system_conn, &reserved_slugs, owner_user_id); + } + return Err(e); + } + } + } + + if let Err(e) = tx.commit() { + if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") { + release_reserved(&system_conn, &reserved_slugs, owner_user_id); + } + return Err(BulkUrlError::Internal(format!( + "Failed to commit transaction: {e}" + ))); + } + + // Activate slugs + { + let system_conn = crate::utils::lock_db(system_db, "system_db") + .map_err(|e| BulkUrlError::Internal(e.to_string()))?; + for url in &created_urls { + let _ = system_conn.execute( + "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code], + ); + } + } + + // Increment quota counters + { + let users_conn = crate::utils::lock_db(users_db, "users_db") + .map_err(|e| BulkUrlError::Internal(e.to_string()))?; + for _ in 0..created_urls.len() { + let _ = crate::db::users::increment_quota_counter(&users_conn, owner_user_id, "urls"); + } + } + + Ok(created_urls) +} + +fn create_one_in_tx( + tx: &Transaction<'_>, + system_db: &Mutex, + owner_user_id: i64, + item: BulkUrlCreateItem, + reserved_slugs: &mut Vec, +) -> Result { + let mut code = item.code.unwrap_or_default().trim().to_lowercase(); + if code.is_empty() { + code = generate_token(3); + } else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(BulkUrlError::BadRequest(format!( + "Short code '{code}' must be 6 hex characters" + ))); + } + + { + let system_conn = crate::utils::lock_db(system_db, "system_db") + .map_err(|e| BulkUrlError::Internal(e.to_string()))?; + let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) + && !reserved_slugs.contains(&code); + if !available { + return Err(BulkUrlError::Conflict(format!( + "Short code '{code}' already exists" + ))); + } + if let Err(e) = crate::db::users::register_global_slug( + &system_conn, + &code, + owner_user_id, + "url", + "", + "reserving", + ) { + return Err(BulkUrlError::Internal(format!( + "Failed to reserve slug '{code}': {e}" + ))); + } + reserved_slugs.push(code.clone()); + } + + let password_hash = if let Some(ref pwd) = item.password { + match hash_password(pwd) { + Ok(h) => Some(h), + Err(e) => { + return Err(BulkUrlError::Internal(format!( + "Password hashing error: {e}" + ))); + } + } + } else { + None + }; + + if !validate_redirect_destination(&item.destination) { + return Err(BulkUrlError::BadRequest(format!( + "Invalid destination for item '{code}': must be a valid http(s) URL without control characters" + ))); + } + + let tags = item.tags.unwrap_or_default(); + crate::db::content::create_url_extended( + tx, + &code, + &item.destination, + item.title.as_deref(), + item.description.as_deref(), + &tags, + item.expires_at.as_deref(), + password_hash.as_deref(), + item.max_access_count, + ) + .map_err(|e| BulkUrlError::Internal(format!("Database insert error: {e}"))) +} diff --git a/src/services/destination_audit.rs b/src/services/destination_audit.rs new file mode 100644 index 0000000..49da089 --- /dev/null +++ b/src/services/destination_audit.rs @@ -0,0 +1,261 @@ +//! Read-only audit of stored redirect destinations. +//! +//! Scans tenant content databases and classifies each `urls.destination` using +//! the same rules as write-path validation. Never rewrites or deletes data. + +use crate::db::Db; +use crate::utils::validation::{classify_redirect_destination, DestinationClass}; +use rusqlite::Connection; +use std::path::Path; +use tracing::{error, info, warn}; + +/// Summary counters for a destination audit run. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct DestinationAuditReport { + pub scanned_users: usize, + pub total_urls: usize, + pub valid_http: usize, + pub valid_https: usize, + pub invalid: usize, + pub control_characters: usize, + pub unsupported_scheme: usize, + pub malformed: usize, + pub empty: usize, + pub too_long: usize, + pub non_ascii: usize, + /// Safe sample of invalid records: (owner_user_id, code, class_label). + /// Destination bodies are never included (may contain control chars / secrets). + pub invalid_samples: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidDestinationSample { + pub owner_user_id: i64, + pub code: String, + pub url_id: String, + pub class: &'static str, + pub destination_len: usize, +} + +const MAX_SAMPLES: usize = 50; + +fn class_label(c: DestinationClass) -> &'static str { + match c { + DestinationClass::ValidHttp => "valid_http", + DestinationClass::ValidHttps => "valid_https", + DestinationClass::Empty => "empty", + DestinationClass::TooLong => "too_long", + DestinationClass::ControlCharacters => "control_characters", + DestinationClass::NonAscii => "non_ascii", + DestinationClass::UnsupportedScheme => "unsupported_scheme", + DestinationClass::Malformed => "malformed", + } +} + +/// Classify a single destination and update report counters. +pub fn record_destination( + report: &mut DestinationAuditReport, + owner_user_id: i64, + code: &str, + url_id: &str, + destination: &str, +) { + report.total_urls += 1; + let class = classify_redirect_destination(destination); + match class { + DestinationClass::ValidHttp => report.valid_http += 1, + DestinationClass::ValidHttps => report.valid_https += 1, + DestinationClass::Empty => { + report.empty += 1; + report.invalid += 1; + } + DestinationClass::TooLong => { + report.too_long += 1; + report.invalid += 1; + } + DestinationClass::ControlCharacters => { + report.control_characters += 1; + report.invalid += 1; + } + DestinationClass::NonAscii => { + report.non_ascii += 1; + report.invalid += 1; + } + DestinationClass::UnsupportedScheme => { + report.unsupported_scheme += 1; + report.invalid += 1; + } + DestinationClass::Malformed => { + report.malformed += 1; + report.invalid += 1; + } + } + + if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES { + report.invalid_samples.push(InvalidDestinationSample { + owner_user_id, + code: code.to_string(), + url_id: url_id.to_string(), + class: class_label(class), + destination_len: destination.len(), + }); + } +} + +/// Scan one content database connection for URL destinations. +pub fn audit_content_conn( + conn: &Connection, + owner_user_id: i64, + report: &mut DestinationAuditReport, +) -> rusqlite::Result<()> { + let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + + for row in rows { + let (id, code, destination) = row?; + record_destination(report, owner_user_id, &code, &id, &destination); + } + Ok(()) +} + +fn open_user_content(data_dir: &Path, user_id: i64) -> Result { + let path = data_dir + .join("users") + .join(user_id.to_string()) + .join("content.db"); + if !path.exists() { + return Err(rusqlite::Error::InvalidPath(path)); + } + let conn = Connection::open(path)?; + crate::db::sqlite::enable_wal(&conn, "content")?; + Ok(conn) +} + +/// Audit all tenant content databases found under the configured data directory. +/// +/// Read-only: does not modify any records. +pub fn audit_all_destinations(db: &Db) -> Result { + let mut report = DestinationAuditReport::default(); + + let user_ids: Vec = { + let users = db + .users + .lock() + .map_err(|e| format!("users_db lock poisoned: {}", e))?; + let mut stmt = users + .prepare("SELECT id FROM users;") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| row.get(0)) + .map_err(|e| e.to_string())?; + rows.filter_map(|r| r.ok()).collect() + }; + + for user_id in user_ids { + match open_user_content(&db.data_dir, user_id) { + Ok(conn) => { + report.scanned_users += 1; + if let Err(e) = audit_content_conn(&conn, user_id, &mut report) { + error!( + owner_user_id = user_id, + error = %e, + "destination audit failed for user content.db" + ); + return Err(format!("audit user {} content.db: {}", user_id, e)); + } + } + Err(rusqlite::Error::InvalidPath(_)) => { + // User has no content DB yet — skip. + } + Err(e) => { + warn!( + owner_user_id = user_id, + error = %e, + "could not open user content.db for destination audit" + ); + } + } + } + + info!( + total_urls = report.total_urls, + valid = report.valid_http + report.valid_https, + invalid = report.invalid, + "destination audit complete" + ); + Ok(report) +} + +/// Format a human-readable report for CLI output. +pub fn format_report(report: &DestinationAuditReport) -> String { + let mut out = String::new(); + out.push_str("BZOD Redirect Destination Audit (read-only)\n"); + out.push_str("===========================================\n"); + out.push_str(&format!("Users scanned: {}\n", report.scanned_users)); + out.push_str(&format!("Total URLs: {}\n", report.total_urls)); + out.push_str(&format!("Valid HTTP: {}\n", report.valid_http)); + out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https)); + out.push_str(&format!("Invalid (total): {}\n", report.invalid)); + out.push_str(&format!( + " control characters: {}\n", + report.control_characters + )); + out.push_str(&format!( + " unsupported scheme: {}\n", + report.unsupported_scheme + )); + out.push_str(&format!(" malformed: {}\n", report.malformed)); + out.push_str(&format!(" empty: {}\n", report.empty)); + out.push_str(&format!(" too long: {}\n", report.too_long)); + out.push_str(&format!(" non-ascii: {}\n", report.non_ascii)); + + if !report.invalid_samples.is_empty() { + out.push_str("\nInvalid samples (id/code only; destinations not printed):\n"); + for s in &report.invalid_samples { + out.push_str(&format!( + " user={} code={} id={} class={} dest_len={}\n", + s.owner_user_id, s.code, s.url_id, s.class, s.destination_len + )); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_control_character_destination() { + let mut report = DestinationAuditReport::default(); + record_destination( + &mut report, + 1, + "ab12cd", + "id-1", + "https://evil.example/\r\nX:1", + ); + assert_eq!(report.total_urls, 1); + assert_eq!(report.invalid, 1); + assert_eq!(report.control_characters, 1); + assert_eq!(report.invalid_samples.len(), 1); + assert_eq!(report.invalid_samples[0].class, "control_characters"); + // Ensure we never store the destination body in the sample. + assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil")); + } + + #[test] + fn records_valid_https() { + let mut report = DestinationAuditReport::default(); + record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok"); + assert_eq!(report.valid_https, 1); + assert_eq!(report.invalid, 0); + assert!(report.invalid_samples.is_empty()); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 393ce95..b28622f 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,7 +1,12 @@ pub mod api_keys; pub mod audit; +pub mod backup_layout; pub mod bulk; +pub mod bulk_urls; +pub mod destination_audit; pub mod landing_pages; pub mod qr; pub mod registry_validator; pub mod shortener; +pub mod slug_transfer; +pub mod urls; diff --git a/src/services/shortener.rs b/src/services/shortener.rs index 7ce4ad4..65d0f61 100644 --- a/src/services/shortener.rs +++ b/src/services/shortener.rs @@ -10,13 +10,24 @@ pub fn create_url( description: Option<&str>, tags: &[String], ) -> Result { - let conn = db.content.lock().unwrap(); + if !crate::utils::validation::validate_redirect_destination(destination) { + return Err(AppError::BadRequest( + "Destination must be a valid http(s) URL without control characters".into(), + )); + } + let conn = db + .content + .lock() + .map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?; let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?; Ok(url) } pub fn get_url_by_code(db: &Db, code: &str) -> Result, AppError> { - let conn = db.content.lock().unwrap(); + let conn = db + .content + .lock() + .map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?; let url = crate::db::content::get_url_by_code(&conn, code)?; Ok(url) } diff --git a/src/services/slug_transfer.rs b/src/services/slug_transfer.rs new file mode 100644 index 0000000..6b72a5e --- /dev/null +++ b/src/services/slug_transfer.rs @@ -0,0 +1,243 @@ +//! Cross-tenant slug transfer business logic. +//! +//! Copies URL/page content between tenant content DBs, then updates global_slugs +//! ownership. Handlers own admin auth and HTTP mapping. + +use crate::state::{AppState, UserDbs}; +use crate::utils::lock_db; +use chrono::Utc; +use rusqlite::OptionalExtension; + +#[derive(Debug)] +pub enum TransferError { + NotFound(&'static str), + BadRequest(String), + Internal(String), +} + +impl TransferError { + pub fn message(&self) -> String { + match self { + Self::NotFound(m) => (*m).to_string(), + Self::BadRequest(m) | Self::Internal(m) => m.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct SlugTransferRequest { + pub slug: String, + pub new_owner_user_id: i64, +} + +#[derive(Debug)] +pub struct SlugTransferResult { + pub old_owner_user_id: i64, + pub new_owner_user_id: i64, + pub target_type: String, + pub new_target_id: String, +} + +/// Look up slug ownership in `global_slugs`. +pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> { + let system_conn = lock_db(&state.system_db, "system_db") + .map_err(|e| TransferError::Internal(e.to_string()))?; + let mut stmt = system_conn + .prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;") + .map_err(|e| TransferError::Internal(e.to_string()))?; + let row_opt = stmt + .query_row([slug], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .optional() + .map_err(|e| TransferError::Internal(e.to_string()))?; + + match row_opt { + Some(r) => Ok(r), + None => Err(TransferError::NotFound("Slug not found")), + } +} + +/// Copy content row between tenants and return the new target id. +fn copy_content( + state: &AppState, + old_dbs: &UserDbs, + new_dbs: &UserDbs, + slug: &str, + target_type: &str, + new_owner_user_id: i64, +) -> Result { + let old_conn = lock_db(&old_dbs.content, "old_content_db") + .map_err(|e| TransferError::Internal(e.to_string()))?; + let new_conn = lock_db(&new_dbs.content, "new_content_db") + .map_err(|e| TransferError::Internal(e.to_string()))?; + + if target_type == "url" { + let url = match crate::db::content::get_url_by_code(&old_conn, slug) { + Ok(Some(u)) => u, + Ok(None) => { + return Err(TransferError::NotFound( + "Content not found in owner database", + )) + } + Err(e) => return Err(TransferError::Internal(e.to_string())), + }; + + { + let new_users_conn = lock_db(&state.users_db, "users_db") + .map_err(|e| TransferError::Internal(e.to_string()))?; + if let Ok(Some(quota)) = + crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id) + { + if quota.current_urls >= quota.max_urls { + return Err(TransferError::BadRequest( + "New owner has exceeded URL quota limit".into(), + )); + } + } + } + + let new_url = crate::db::content::create_url_extended( + &new_conn, + &url.code, + &url.destination, + url.title.as_deref(), + url.description.as_deref(), + &url.tags, + url.expires_at.as_deref(), + url.password_hash.as_deref(), + url.max_access_count, + ) + .map_err(|e| TransferError::Internal(format!("Failed to copy URL to new owner: {e}")))?; + let _ = crate::db::content::delete_url(&old_conn, &url.id); + Ok(new_url.id) + } else if target_type == "page" { + let page = match crate::db::content::get_landing_page_by_code(&old_conn, slug) { + Ok(Some(p)) => p, + Ok(None) => { + return Err(TransferError::NotFound( + "Content not found in owner database", + )) + } + Err(e) => return Err(TransferError::Internal(e.to_string())), + }; + + { + let new_users_conn = lock_db(&state.users_db, "users_db") + .map_err(|e| TransferError::Internal(e.to_string()))?; + if let Ok(Some(quota)) = + crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id) + { + if quota.current_landings >= quota.max_landings { + return Err(TransferError::BadRequest( + "New owner has exceeded landing page quota limit".into(), + )); + } + } + } + + let new_page = crate::db::content::create_landing_page( + &new_conn, + &page.code, + &page.slug, + &page.title, + &page.html_content, + &page.state, + ) + .map_err(|e| TransferError::Internal(format!("Failed to copy Page to new owner: {e}")))?; + let _ = crate::db::content::delete_landing_page(&old_conn, &page.id); + Ok(new_page.id) + } else { + Err(TransferError::NotFound( + "Content not found in owner database", + )) + } +} + +/// Perform a full slug transfer (content + registry + quotas + history). +pub fn transfer_slug( + state: &AppState, + req: &SlugTransferRequest, + admin_username: &str, +) -> Result { + let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?; + + if old_owner_user_id == req.new_owner_user_id { + return Err(TransferError::BadRequest( + "New owner must be different from the current owner".into(), + )); + } + + let old_dbs = state + .get_user_dbs(old_owner_user_id) + .map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?; + let new_dbs = state + .get_user_dbs(req.new_owner_user_id) + .map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?; + + let new_target_id = copy_content( + state, + &old_dbs, + &new_dbs, + &req.slug, + &target_type, + req.new_owner_user_id, + )?; + + { + let system_conn = lock_db(&state.system_db, "system_db") + .map_err(|e| TransferError::Internal(e.to_string()))?; + let now = Utc::now().to_rfc3339(); + + let _ = system_conn.execute( + "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", + rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug], + ); + + let _ = system_conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) + VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", + rusqlite::params![ + req.slug, + old_owner_user_id, + req.new_owner_user_id, + now, + admin_username + ], + ); + + let users_conn = lock_db(&state.users_db, "users_db") + .map_err(|e| TransferError::Internal(e.to_string()))?; + let field = if target_type == "url" { + "urls" + } else { + "landings" + }; + let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field); + let _ = + crate::db::users::increment_quota_counter(&users_conn, req.new_owner_user_id, field); + + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + admin_username, + "SLUG_TRANSFER", + "slug", + &req.slug, + Some(&format!( + "From owner {} to owner {}", + old_owner_user_id, req.new_owner_user_id + )), + ); + } + + Ok(SlugTransferResult { + old_owner_user_id, + new_owner_user_id: req.new_owner_user_id, + target_type, + new_target_id, + }) +} diff --git a/src/services/urls.rs b/src/services/urls.rs new file mode 100644 index 0000000..4f162bf --- /dev/null +++ b/src/services/urls.rs @@ -0,0 +1,117 @@ +//! Shared URL write-path helpers used by admin UI, tenant UI, and REST API. +//! +//! Handlers remain responsible for auth/CSRF/quotas; this module owns pure +//! destination preparation that must stay consistent across entry points. + +use crate::utils::validation::validate_redirect_destination; + +/// Optional UTM parameters applied to a destination URL. +#[derive(Debug, Default, Clone)] +pub struct UtmParams<'a> { + pub source: Option<&'a str>, + pub medium: Option<&'a str>, + pub campaign: Option<&'a str>, +} + +/// Normalize and optionally append UTM parameters to a destination. +/// +/// Returns `Err` when the base destination fails canonical validation. +/// UTM appending only runs when the base parses as a URL (same as prior handlers). +pub fn prepare_destination(raw: &str, utm: UtmParams<'_>) -> Result { + let mut dest = raw.trim().to_string(); + if !validate_redirect_destination(&dest) { + return Err("Destination must be a valid http(s) URL without control characters"); + } + + if let Ok(mut parsed) = reqwest::Url::parse(&dest) { + let mut has_utm = false; + { + let mut query = parsed.query_pairs_mut(); + if let Some(src) = utm.source { + let src = src.trim(); + if !src.is_empty() { + query.append_pair("utm_source", src); + has_utm = true; + } + } + if let Some(med) = utm.medium { + let med = med.trim(); + if !med.is_empty() { + query.append_pair("utm_medium", med); + has_utm = true; + } + } + if let Some(camp) = utm.campaign { + let camp = camp.trim(); + if !camp.is_empty() { + query.append_pair("utm_campaign", camp); + has_utm = true; + } + } + } + if has_utm { + dest = parsed.to_string(); + } + } + + Ok(dest) +} + +/// Parse HTML datetime-local / partial RFC3339 expiry input into RFC3339 if present. +pub fn parse_expires_at_input(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let mut rfc = trimmed.to_string(); + if rfc.len() == 16 { + // HTML datetime-local → assume UTC seconds + rfc.push_str(":00Z"); + } + Some(rfc) +} + +/// Parse optional max-access-count form field. +pub fn parse_max_access_count(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + trimmed.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prepare_rejects_crlf() { + let err = prepare_destination("https://x/\r\nY:1", UtmParams::default()).unwrap_err(); + assert!(err.contains("valid http")); + } + + #[test] + fn prepare_appends_utm() { + let dest = prepare_destination( + "https://example.com/path", + UtmParams { + source: Some("newsletter"), + medium: Some("email"), + campaign: Some("spring"), + }, + ) + .unwrap(); + assert!(dest.contains("utm_source=newsletter")); + assert!(dest.contains("utm_medium=email")); + assert!(dest.contains("utm_campaign=spring")); + } + + #[test] + fn parse_expires_datetime_local() { + assert_eq!( + parse_expires_at_input("2030-01-01T12:00"), + Some("2030-01-01T12:00:00Z".to_string()) + ); + assert_eq!(parse_expires_at_input(" "), None); + } +} diff --git a/src/state.rs b/src/state.rs index 692c53f..341e0e8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -29,7 +29,7 @@ pub struct AppState { impl AppState { pub fn get_user_dbs(&self, user_id: i64) -> Result { - let mut pool = self.user_dbs.lock().unwrap(); + let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?; if let Some(dbs) = pool.get(&user_id) { return Ok(dbs.clone()); } @@ -87,12 +87,12 @@ impl AppState { Ok(dbs) } - pub fn db_compact(&self) -> Result<(), rusqlite::Error> { - self.admin_db.lock().unwrap().execute("VACUUM;", [])?; - self.content_db.lock().unwrap().execute("VACUUM;", [])?; - self.analytics_db.lock().unwrap().execute("VACUUM;", [])?; - self.system_db.lock().unwrap().execute("VACUUM;", [])?; - self.users_db.lock().unwrap().execute("VACUUM;", [])?; + pub fn db_compact(&self) -> Result<(), crate::error::AppError> { + crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?; + crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?; + crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?; + crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?; + crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?; Ok(()) } } diff --git a/src/utils/db_lock.rs b/src/utils/db_lock.rs new file mode 100644 index 0000000..5e17406 --- /dev/null +++ b/src/utils/db_lock.rs @@ -0,0 +1,61 @@ +//! Poison-safe helpers for `std::sync::Mutex` around SQLite connections. +//! +//! Prefer these on request paths so a poisoned mutex returns a controlled error +//! instead of panicking the worker thread. + +use crate::error::AppError; +use std::sync::{Mutex, MutexGuard}; +use tracing::error; + +/// Acquire a database mutex, mapping poison to [`AppError::Internal`]. +/// +/// Logs the mutex name (not connection contents or secrets). +pub fn lock_db<'a, T>( + mutex: &'a Mutex, + name: &'static str, +) -> Result, AppError> { + mutex.lock().map_err(|e| { + error!(mutex = name, error = %e, "database mutex poisoned"); + AppError::Internal(format!("{name} mutex poisoned")) + }) +} + +/// Acquire a database mutex, mapping poison to a plain error string. +/// +/// Useful for handlers that return `(StatusCode, String)` rather than `AppError`. +pub fn lock_db_str<'a, T>( + mutex: &'a Mutex, + name: &'static str, +) -> Result, String> { + mutex.lock().map_err(|e| { + error!(mutex = name, error = %e, "database mutex poisoned"); + format!("{name} mutex poisoned") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[test] + fn lock_db_succeeds_on_healthy_mutex() { + let m = Mutex::new(42); + let g = lock_db(&m, "test").unwrap(); + assert_eq!(*g, 42); + } + + #[test] + fn lock_db_maps_poison() { + let m = Mutex::new(1); + let _ = std::panic::catch_unwind(|| { + let _g = m.lock().unwrap(); + panic!("poison"); + }); + let err = lock_db(&m, "poisoned_db").unwrap_err(); + match err { + AppError::Internal(msg) => assert!(msg.contains("poisoned_db")), + other => panic!("unexpected {other:?}"), + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index cc21ce6..8abb509 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod db_lock; pub mod hashing; pub mod network; pub mod random; @@ -5,8 +6,9 @@ pub mod system; pub mod time; pub mod validation; +pub use db_lock::{lock_db, lock_db_str}; pub use hashing::sha256_hash; -pub use network::get_client_ip; +pub use network::{get_client_ip, resolve_cookie_secure}; pub use random::generate_token; pub use system::{get_db_file_info, get_memory_usage}; pub use time::format_duration; diff --git a/src/utils/network.rs b/src/utils/network.rs index c10d4a1..0aa3457 100644 --- a/src/utils/network.rs +++ b/src/utils/network.rs @@ -22,3 +22,119 @@ pub fn get_client_ip(headers: &HeaderMap, connect_info: Option &str { + if host_header.starts_with('[') { + if let Some(end_idx) = host_header.find(']') { + return &host_header[1..end_idx]; + } + } + host_header.split(':').next().unwrap_or(host_header) +} + +/// Determines whether to set the `Secure` flag on a cookie based on deployment context. +/// +/// Policy: +/// 1. If X-Forwarded-Proto is explicitly "https", always enforce Secure=true. +/// (We assume X-Forwarded-Proto is from a trusted proxy. Forged "https" only makes +/// the cookie safer. We never weaken based on X-Forwarded-Proto=http). +/// 2. If the exact Host is a local loopback (localhost, 127.0.0.1, ::1) and we are not +/// explicitly proxied via HTTPS, disable Secure. This prevents browsers from dropping +/// the cookie during local development over cleartext HTTP. +/// 3. Otherwise, fall back to the global `cookie_secure` config (which defaults to true +/// to keep production secure-by-default even if the proxy strips X-Forwarded-Proto). +pub fn resolve_cookie_secure(config_secure: bool, headers: &HeaderMap) -> bool { + // 1. Explicit HTTPS via reverse proxy + if let Some(proto) = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + { + if proto.eq_ignore_ascii_case("https") { + return true; + } + } + + // 2. Exact loopback development (prevent cookie drop) + if let Some(host_hdr) = headers.get("host").and_then(|h| h.to_str().ok()) { + let hostname = extract_hostname(host_hdr); + if matches!(hostname, "localhost" | "127.0.0.1" | "::1") { + return false; + } + } + + // 3. Global config (normally true) + config_secure +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + #[test] + fn test_extract_hostname() { + assert_eq!(extract_hostname("localhost"), "localhost"); + assert_eq!(extract_hostname("localhost:8080"), "localhost"); + assert_eq!(extract_hostname("127.0.0.1"), "127.0.0.1"); + assert_eq!(extract_hostname("127.0.0.1:8080"), "127.0.0.1"); + assert_eq!(extract_hostname("[::1]"), "::1"); + assert_eq!(extract_hostname("[::1]:8080"), "::1"); + assert_eq!(extract_hostname("example.com"), "example.com"); + assert_eq!(extract_hostname("example.com:443"), "example.com"); + assert_eq!( + extract_hostname("localhost.example.com"), + "localhost.example.com" + ); + } + + #[test] + fn test_resolve_cookie_secure() { + let mut headers = HeaderMap::new(); + + // No headers, global config is true -> Secure=true + assert!(resolve_cookie_secure(true, &headers)); + // No headers, global config is false -> Secure=false + assert!(!resolve_cookie_secure(false, &headers)); + + // Exact loopback matches -> Secure=false + let loopback_hosts = [ + "localhost", + "localhost:8080", + "127.0.0.1", + "127.0.0.1:8080", + "[::1]", + "[::1]:8080", + ]; + for host in loopback_hosts { + headers.insert("host", HeaderValue::from_static(host)); + assert!( + !resolve_cookie_secure(true, &headers), + "Failed for host: {}", + host + ); + } + + // Non-loopback localhost subdomains -> Secure=true (relying on config) + let public_hosts = ["localhost.example.com", "127.0.0.2", "example.com"]; + for host in public_hosts { + headers.insert("host", HeaderValue::from_static(host)); + assert!( + resolve_cookie_secure(true, &headers), + "Failed for host: {}", + host + ); + } + + // X-Forwarded-Proto = https overrides loopback + headers.insert("host", HeaderValue::from_static("localhost")); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); + assert!(resolve_cookie_secure(true, &headers)); + assert!(resolve_cookie_secure(false, &headers)); // Overrides false config too + + // X-Forwarded-Proto = http DOES NOT override global config + headers.insert("host", HeaderValue::from_static("example.com")); + headers.insert("x-forwarded-proto", HeaderValue::from_static("http")); + assert!(resolve_cookie_secure(true, &headers)); // Still true because of config + } +} diff --git a/src/utils/validation.rs b/src/utils/validation.rs index d853166..13a21a9 100644 --- a/src/utils/validation.rs +++ b/src/utils/validation.rs @@ -17,3 +17,144 @@ pub fn validate_redirect_code(code: &str) -> bool { pub fn validate_page_code(code: &str) -> bool { (code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code) } + +/// Classification of a stored or proposed redirect destination. +/// +/// Used by write-path validation and read-only legacy data audits. Order of checks +/// matches `validate_redirect_destination` so both share the same rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DestinationClass { + ValidHttp, + ValidHttps, + Empty, + TooLong, + ControlCharacters, + NonAscii, + UnsupportedScheme, + Malformed, +} + +impl DestinationClass { + pub fn is_valid(self) -> bool { + matches!(self, Self::ValidHttp | Self::ValidHttps) + } +} + +fn scheme_prefix(dest: &str) -> Option<&str> { + let end = dest.find(':')?; + let scheme = &dest[..end]; + if scheme.is_empty() { + return None; + } + if scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-') + { + Some(scheme) + } else { + None + } +} + +/// Classify a destination using the same rules as write-path validation. +pub fn classify_redirect_destination(destination: &str) -> DestinationClass { + let dest = destination.trim(); + if dest.is_empty() { + return DestinationClass::Empty; + } + if dest.len() > 2048 { + return DestinationClass::TooLong; + } + // HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls. + if dest.bytes().any(|b| b < 0x20 || b == 0x7f) { + return DestinationClass::ControlCharacters; + } + // HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII. + if !dest.is_ascii() { + return DestinationClass::NonAscii; + } + // Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:). + if let Some(scheme) = scheme_prefix(dest) { + let scheme_l = scheme.to_ascii_lowercase(); + if scheme_l != "http" && scheme_l != "https" { + return DestinationClass::UnsupportedScheme; + } + } + match reqwest::Url::parse(dest) { + Ok(url) => { + if url.host_str().is_none() { + return DestinationClass::Malformed; + } + match url.scheme() { + "http" => DestinationClass::ValidHttp, + "https" => DestinationClass::ValidHttps, + _ => DestinationClass::UnsupportedScheme, + } + } + Err(_) => DestinationClass::Malformed, + } +} + +/// Returns true if `destination` is safe to store and emit as an HTTP Location value. +/// +/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values, +/// and non-http(s) schemes. The redirect handler remains defensive even if invalid +/// values already exist in older data. +pub fn validate_redirect_destination(destination: &str) -> bool { + classify_redirect_destination(destination).is_valid() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_crlf_destination() { + assert!(!validate_redirect_destination( + "https://example.com/\r\nX-Injected: 1" + )); + assert!(!validate_redirect_destination( + "https://example.com/\nX-Injected: 1" + )); + } + + #[test] + fn rejects_non_http_schemes() { + assert!(!validate_redirect_destination("javascript:alert(1)")); + assert!(!validate_redirect_destination("data:text/html,hi")); + assert!(!validate_redirect_destination("/relative/path")); + } + + #[test] + fn accepts_normal_https() { + assert!(validate_redirect_destination( + "https://example.com/path?q=1#frag" + )); + assert!(validate_redirect_destination("http://localhost:8080/x")); + } + + #[test] + fn classifies_destination_categories() { + assert_eq!( + classify_redirect_destination("https://ok.example/"), + DestinationClass::ValidHttps + ); + assert_eq!( + classify_redirect_destination("http://ok.example/"), + DestinationClass::ValidHttp + ); + assert_eq!( + classify_redirect_destination("javascript:alert(1)"), + DestinationClass::UnsupportedScheme + ); + assert_eq!( + classify_redirect_destination("https://x/\r\nX:1"), + DestinationClass::ControlCharacters + ); + assert_eq!( + classify_redirect_destination("not a url"), + DestinationClass::Malformed + ); + assert_eq!(classify_redirect_destination(""), DestinationClass::Empty); + } +} diff --git a/src/web/admin.rs b/src/web/admin.rs deleted file mode 100644 index ee453af..0000000 --- a/src/web/admin.rs +++ /dev/null @@ -1,7065 +0,0 @@ -use axum::{ - extract::{ConnectInfo, Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Redirect, Response}, - Form, -}; -use axum_extra::extract::cookie::Cookie; -use axum_extra::extract::CookieJar; -use chrono::Utc; -use flate2::read::GzDecoder; -use flate2::write::GzEncoder; -use flate2::Compression; -use rusqlite::{params, OptionalExtension}; -use serde::Deserialize; -use std::collections::HashMap; -use std::fs::File; -use std::net::SocketAddr; -use tar::Builder; -use uuid::Uuid; - -use crate::db::admin::{ - create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config, - write_audit_log as write_audit_log_legacy, -}; - -#[allow(clippy::too_many_arguments)] -fn write_audit_log( - conn: &rusqlite::Connection, - state: &AppState, - username: &str, - action: &str, - object_type: Option<&str>, - object_id: Option<&str>, - ip_address: Option<&str>, - user_agent: Option<&str>, -) -> rusqlite::Result { - let res = write_audit_log_legacy( - conn, - username, - action, - object_type, - object_id, - ip_address, - user_agent, - ); - - // Also write to unified audit events in system.db - let system_conn = state.system_db.lock().unwrap(); - let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - username, - action, - object_type.unwrap_or(""), - object_id.unwrap_or(""), - Some(&metadata), - ); - - res -} - -use crate::auth::{ - authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token, - hash_password, verify_csrf, verify_password, verify_sha256, -}; -use crate::charts::{generate_bar_chart, generate_line_chart}; -use crate::db::analytics::{ - clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, - get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors, - get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated, - get_total_clicks, get_visits_schema_columns, parse_ua, -}; -use crate::db::content::{ - create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, - get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts, - list_landing_pages, list_urls, -}; -use crate::models::User; -use crate::state::AppState; -use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage}; - -const PAGE_SIZE: usize = 25; -const ANALYTICS_PAGE_SIZE: usize = 50; -const MAX_JSON_EXPORT_ROWS: usize = 50_000; - -// Helper: Verify admin session and return user or redirect to login -async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> { - let conn = state.users_db.lock().unwrap(); - match authenticate_admin_session(&conn, jar) { - Ok(Some((user, session_id))) => Ok((user, session_id)), - _ => Err(Redirect::to("/admin/login")), - } -} - -// Helper: Verify tenant user session and return user or redirect to login -async fn require_user_auth( - state: &AppState, - jar: &CookieJar, -) -> Result<(crate::models::TenantUser, String), Redirect> { - let conn = state.users_db.lock().unwrap(); - match authenticate_user_session(&conn, jar) { - Ok(Some((user, session_id))) => Ok((user, session_id)), - _ => Err(Redirect::to("/login")), - } -} - -// GET /admin -pub async fn admin_index(State(state): State, jar: CookieJar) -> Response { - match require_auth(&state, &jar).await { - Ok(_) => Redirect::to("/admin/dashboard").into_response(), - Err(redir) => redir.into_response(), - } -} - -// GET /admin/login -pub async fn login_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let error = params.get("error").cloned(); - let csrf_token = generate_token(16); - let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone())) - .path("/admin/login") - .secure(state.config.cookie_secure) - .http_only(true) - .same_site(axum_extra::extract::cookie::SameSite::Strict) - .max_age(time::Duration::minutes(10)) - .build(); - - let new_jar = jar.add(cookie); - let template = crate::templates::LoginTemplate { - error, - csrf_token, - action: "/admin/login".to_string(), - title: "Admin Login".to_string(), - subtitle: "Administrative Access".to_string(), - button_text: "Sign In".to_string(), - }; - (new_jar, template).into_response() -} - -#[derive(Deserialize)] -pub struct LoginForm { - pub username: String, - pub password: String, - pub csrf_token: String, -} - -// POST /admin/login -pub async fn login_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let temp_csrf = jar - .get("bzod_temp_csrf") - .map(|c| c.value().to_string()) - .unwrap_or_default(); - if temp_csrf.is_empty() || temp_csrf != form.csrf_token { - return Redirect::to("/admin/login?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - - let (user_count, admin_count, active_session_count) = { - let conn = state.users_db.lock().unwrap(); - let u_count: i64 = conn - .query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0)) - .unwrap_or(0); - let a_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM users WHERE account_type = 'admin';", - [], - |r| r.get(0), - ) - .unwrap_or(0); - let now = Utc::now().to_rfc3339(); - let s_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;", - [now], - |r| r.get(0), - ) - .unwrap_or(0); - (u_count, a_count, s_count) - }; - - let bootstrap_allowed = user_count <= 1 && admin_count == 0 && active_session_count == 0; - - let user_opt = if bootstrap_allowed - && form.username == state.config.admin_username - && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) - { - // Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256 - let hash = match hash_password(&form.password) { - Ok(h) => h, - Err(_) => { - return Redirect::to("/admin/login?error=Internal hashing error").into_response() - } - }; - - let conn = state.users_db.lock().unwrap(); - match crate::db::users::create_admin_user(&conn, &form.username, &hash) { - Ok(u) => { - // Initialize user specific directory and DB files - if let Err(e) = state.db.init_user_databases(u.id) { - tracing::error!( - "Failed to init user databases during admin bootstrap: {:?}", - e - ); - } - - // Write audit event in system.db - let system_conn = state.system_db.lock().unwrap(); - let metadata = format!( - "IP: {:?}, UA: {:?}", - ip, - headers.get("user-agent").and_then(|h| h.to_str().ok()) - ); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &u.username, - "BOOTSTRAP_USER_PROVISIONED", - "user", - &u.id.to_string(), - Some(&metadata), - ); - - Some(User { - id: u.id.to_string(), - username: u.username, - password_hash: u.password_hash, - created_at: u.created_at, - }) - } - Err(e) => { - tracing::error!("Failed to create admin user during bootstrap: {:?}", e); - None - } - } - } else { - // Standard DB Verification - let conn = state.users_db.lock().unwrap(); - let user_res: Result, rusqlite::Error> = conn.query_row( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata - FROM users WHERE username = ?1;", - [&form.username], - |row| { - Ok(crate::models::TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - } - ).optional(); - - match user_res { - Ok(Some(u)) => { - if u.status != "active" { - tracing::warn!( - username = form.username, - reason = "account_disabled", - "login rejected" - ); - None - } else if u.account_type != "admin" { - tracing::warn!( - username = form.username, - reason = "insufficient_privileges", - "login rejected" - ); - None - } else if verify_password(&form.password, &u.password_hash) { - Some(User { - id: u.id.to_string(), - username: u.username, - password_hash: u.password_hash, - created_at: u.created_at, - }) - } else { - tracing::warn!( - username = form.username, - reason = "invalid_credentials", - "login rejected" - ); - None - } - } - _ => { - tracing::warn!( - username = form.username, - reason = "user_not_found", - "login rejected" - ); - None - } - } - }; - - match user_opt { - Some(user) => { - let session_token = generate_token(32); - let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); - - { - let conn = state.users_db.lock().unwrap(); - let user_id_i64 = user.id.parse::().unwrap_or(0); - let now = Utc::now().to_rfc3339(); - let _ = conn.execute( - "INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", - rusqlite::params![session_token, user_id_i64, expires, now], - ); - - // Write audit event in system.db - let system_conn = state.system_db.lock().unwrap(); - let metadata = format!( - "IP: {:?}, UA: {:?}", - ip, - headers.get("user-agent").and_then(|h| h.to_str().ok()) - ); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &user.username, - "USER_LOGIN", - "session", - &session_token, - Some(&metadata), - ); - } - - let cookie = Cookie::build(("bzod_session", session_token)) - .path("/") - .secure(state.config.cookie_secure) - .http_only(true) - .same_site(axum_extra::extract::cookie::SameSite::Strict) - .max_age(time::Duration::days(30)) - .build(); - - let clear_temp = Cookie::build("bzod_temp_csrf") - .path("/admin/login") - .max_age(time::Duration::ZERO) - .build(); - - let mut response_jar = jar.clone(); - response_jar = response_jar.add(cookie).add(clear_temp); - - (response_jar, Redirect::to("/admin/dashboard")).into_response() - } - None => { - { - let system_conn = state.system_db.lock().unwrap(); - let metadata = format!( - "IP: {:?}, UA: {:?}", - ip, - headers.get("user-agent").and_then(|h| h.to_str().ok()) - ); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - "anonymous", - "LOGIN_FAILED", - "login", - "", - Some(&metadata), - ); - } - Redirect::to("/admin/login?error=Invalid username or password").into_response() - } - } -} - -// GET /logout -pub async fn public_logout(State(_state): State, jar: CookieJar) -> Response { - let cookie = Cookie::build("bzod_user_session") - .path("/") - .max_age(time::Duration::ZERO) - .build(); - - let mut response_jar = jar.clone(); - response_jar = response_jar.add(cookie); - - (response_jar, Redirect::to("/login")).into_response() -} - -// GET /login -pub async fn public_login_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let error = params.get("error").cloned(); - let csrf_token = generate_token(16); - let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone())) - .path("/login") - .secure(state.config.cookie_secure) - .http_only(true) - .same_site(axum_extra::extract::cookie::SameSite::Strict) - .max_age(time::Duration::minutes(10)) - .build(); - - let new_jar = jar.add(cookie); - let template = crate::templates::LoginTemplate { - error, - csrf_token, - action: "/login".to_string(), - title: "User Login".to_string(), - subtitle: "Standard account access".to_string(), - button_text: "Sign In".to_string(), - }; - (new_jar, template).into_response() -} - -// POST /login -pub async fn public_login_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let temp_csrf = jar - .get("bzod_temp_csrf") - .map(|c| c.value().to_string()) - .unwrap_or_default(); - if temp_csrf.is_empty() || temp_csrf != form.csrf_token { - return Redirect::to("/login?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - - let user_opt: Option = { - let conn = state.users_db.lock().unwrap(); - let user_res: Result, rusqlite::Error> = conn.query_row( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ - FROM users WHERE username = ?1;", - [&form.username], - |row| { - Ok(crate::models::TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - } - ).optional(); - - match user_res { - Ok(Some(u)) => { - if u.status != "active" { - None - } else if verify_password(&form.password, &u.password_hash) { - Some(u) - } else { - None - } - } - _ => None, - } - }; - - match user_opt { - Some(user) => { - let session_token = generate_token(32); - let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); - - { - let conn = state.users_db.lock().unwrap(); - let _ = - crate::db::users::create_user_session(&conn, &session_token, user.id, &expires); - - let system_conn = state.system_db.lock().unwrap(); - let metadata = format!( - "IP: {:?}, UA: {:?}", - ip, - headers.get("user-agent").and_then(|h| h.to_str().ok()) - ); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &user.username, - "USER_LOGIN", - "session", - &session_token, - Some(&metadata), - ); - } - - let cookie = Cookie::build(("bzod_user_session", session_token)) - .path("/") - .secure(state.config.cookie_secure) - .http_only(true) - .same_site(axum_extra::extract::cookie::SameSite::Strict) - .max_age(time::Duration::days(30)) - .build(); - - let clear_temp = Cookie::build("bzod_temp_csrf") - .path("/login") - .max_age(time::Duration::ZERO) - .build(); - - let mut response_jar = jar.clone(); - response_jar = response_jar.add(cookie).add(clear_temp); - - (response_jar, Redirect::to("/user/dashboard")).into_response() - } - None => { - let system_conn = state.system_db.lock().unwrap(); - let metadata = format!( - "IP: {:?}, UA: {:?}", - ip, - headers.get("user-agent").and_then(|h| h.to_str().ok()) - ); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - "anonymous", - "LOGIN_FAILED", - "login", - "", - Some(&metadata), - ); - Redirect::to("/login?error=Invalid username or password").into_response() - } - } -} - -// GET /user/dashboard -pub async fn user_dashboard_get(State(state): State, jar: CookieJar) -> Response { - let (user, _session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let (total_urls, active_links, dead_links) = { - let conn = user_dbs.content.lock().unwrap(); - get_url_counts(&conn).unwrap_or((0, 0, 0)) - }; - - let total_pages = { - let conn = user_dbs.content.lock().unwrap(); - get_landing_page_count(&conn).unwrap_or(0) - }; - - let total_clicks = { - let conn = user_dbs.analytics.lock().unwrap(); - get_total_clicks(&conn).unwrap_or(0) - }; - - let clicks_data = { - let conn = user_dbs.analytics.lock().unwrap(); - get_clicks_trend(&conn, "url", "all", 30) - .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) - .unwrap_or_default() - }; - - let mut trend_map = std::collections::BTreeMap::new(); - for i in (0..30).rev() { - let date_str = (Utc::now() - chrono::Duration::days(i)) - .format("%Y-%m-%d") - .to_string(); - trend_map.insert(date_str, 0i64); - } - for (d, c) in clicks_data { - trend_map.insert(d, c); - } - let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); - let traffic_chart = generate_line_chart(&formatted_trend); - - let countries_data = { - let conn = user_dbs.analytics.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "country", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) - .unwrap_or_default() - }; - let countries_chart = generate_bar_chart(&countries_data); - - let referrers_data = { - let conn = user_dbs.analytics.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "referrer", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) - .unwrap_or_default() - }; - let referrers_chart = generate_bar_chart(&referrers_data); - - let browsers_data = { - let conn = user_dbs.analytics.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "browser", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) - .unwrap_or_default() - }; - let browsers_chart = generate_bar_chart(&browsers_data); - - let template = crate::templates::UserDashboardTemplate { - admin_username: user.username, - total_urls, - total_pages, - total_clicks, - active_links, - dead_links, - traffic_chart, - countries_chart, - browsers_chart, - referrers_chart, - }; - - template.into_response() -} - -#[derive(Deserialize)] -pub struct UserUrlsQuery { - pub tag: Option, - pub error: Option, - pub page: Option, -} - -#[derive(Deserialize)] -pub struct CreateUserUrlForm { - pub destination: String, - #[serde(default)] - pub code: String, - #[serde(default)] - pub custom_slug: String, - #[serde(default)] - pub title: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub tags: String, - pub csrf_token: String, - #[serde(default)] - pub expires_at: String, - #[serde(default)] - pub password: String, - #[serde(default)] - pub max_access_count: String, - #[serde(default)] - pub utm_source: String, - #[serde(default)] - pub utm_medium: String, - #[serde(default)] - pub utm_campaign: String, -} - -pub async fn user_urls_get( - State(state): State, - jar: CookieJar, - Query(query): Query, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let (urls, total_pages, page, visible_pages) = { - let conn = user_dbs.content.lock().unwrap(); - let total_records = if let Some(tag_str) = query.tag.as_deref() { - get_url_count_by_tag(&conn, tag_str).unwrap_or(0) - } else { - get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) - }; - let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); - let total_pages = std::cmp::max(1, calculated_total_pages); - let requested_page = query.page.unwrap_or(1); - let current_page = if requested_page == 0 { - 1 - } else { - requested_page - } - .clamp(1, total_pages); - let offset = (current_page - 1) * PAGE_SIZE; - - let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) - .unwrap_or_default(); - - let start_page = current_page.saturating_sub(3).max(1); - let end_page = std::cmp::min(total_pages, current_page + 3); - let visible_pages: Vec = (start_page..=end_page).collect(); - - (urls, total_pages, current_page, visible_pages) - }; - - let csrf_token = generate_csrf_token(&session_id); - - let proto = if state.config.cookie_secure { - "https" - } else { - "http" - }; - let base_url = state - .config - .base_url - .clone() - .unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port)); - - let template = crate::templates::UserUrlsTemplate { - admin_username: user.username.clone(), - username: user.username, - urls, - csrf_token, - error: query.error, - tag_filter: query.tag, - base_url, - current_page: page, - total_pages, - visible_pages, - }; - - template.into_response() -} - -pub async fn user_urls_create( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/user/urls?error=Invalid CSRF token").into_response(); - } - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let ip = get_client_ip(&headers, connect_info); - - let mut code = form.custom_slug.trim().to_lowercase(); - if code.is_empty() { - code = form.code.trim().to_lowercase(); - if code.is_empty() { - code = generate_token(3); - } else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters") - .into_response(); - } - } else if !crate::utils::validation::validate_custom_slug(&code) { - return Redirect::to( - "/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _", - ) - .into_response(); - } - - { - let users_conn = state.users_db.lock().unwrap(); - if !crate::db::users::check_quota_limit(&users_conn, user.id, "urls").unwrap_or(false) { - return Redirect::to("/user/urls?error=Quota limit exceeded").into_response(); - } - } - - { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { - return Redirect::to("/user/urls?error=Short code/slug already exists").into_response(); - } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, - &code, - user.id, - "url", - "", - "reserving", - ) { - return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e)) - .into_response(); - } - } - - let mut dest = form.destination.trim().to_string(); - if let Ok(mut parsed) = reqwest::Url::parse(&dest) { - let mut has_utm = false; - { - let mut query = parsed.query_pairs_mut(); - if !form.utm_source.trim().is_empty() { - query.append_pair("utm_source", form.utm_source.trim()); - has_utm = true; - } - if !form.utm_medium.trim().is_empty() { - query.append_pair("utm_medium", form.utm_medium.trim()); - has_utm = true; - } - if !form.utm_campaign.trim().is_empty() { - query.append_pair("utm_campaign", form.utm_campaign.trim()); - has_utm = true; - } - } - if has_utm { - dest = parsed.to_string(); - } - } - - let expires_at_opt = if form.expires_at.trim().is_empty() { - None - } else { - let mut rfc = form.expires_at.trim().to_string(); - if rfc.len() == 16 { - rfc.push_str(":00Z"); - } - Some(rfc) - }; - - let password_hash_opt = if form.password.trim().is_empty() { - None - } else { - match hash_password(&form.password) { - Ok(h) => Some(h), - Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(), - } - }; - - let max_access_count_opt = if form.max_access_count.trim().is_empty() { - None - } else { - match form.max_access_count.trim().parse::() { - Ok(c) => Some(c), - Err(_) => { - return Redirect::to("/user/urls?error=Invalid max access count").into_response() - } - } - }; - - let tags_list: Vec = form - .tags - .split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - - let title_opt = if form.title.trim().is_empty() { - None - } else { - Some(form.title.trim()) - }; - let desc_opt = if form.description.trim().is_empty() { - None - } else { - Some(form.description.trim()) - }; - - let res = { - let conn = user_dbs.content.lock().unwrap(); - crate::db::content::create_url_extended( - &conn, - &code, - &dest, - title_opt, - desc_opt, - &tags_list, - expires_at_opt.as_deref(), - password_hash_opt.as_deref(), - max_access_count_opt, - ) - }; - - match res { - Ok(url) => { - { - let system_conn = state.system_db.lock().unwrap(); - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], - ); - } - { - let users_conn = state.users_db.lock().unwrap(); - let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "urls"); - } - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "USER_URL_CREATION", - Some("url"), - Some(&url.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/user/urls").into_response() - } - Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); - Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response() - } - } -} - -pub async fn user_urls_delete( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Path(id): Path, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &csrf_token) { - return Redirect::to("/user/urls?error=Invalid CSRF token").into_response(); - } - - let conn = user_dbs.content.lock().unwrap(); - match get_url_by_id(&conn, &id) { - Ok(Some(url)) => { - let _ = crate::db::users::decrement_quota_counter( - &state.users_db.lock().unwrap(), - user.id, - "urls", - ); - match delete_url(&conn, &id) { - Ok(_) => { - let _ = crate::db::users::release_global_slug( - &state.system_db.lock().unwrap(), - &url.code, - user.id, - ); - let ip = get_client_ip(&headers, connect_info); - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "USER_URL_DELETION", - Some("url"), - Some(&id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/user/urls").into_response() - } - Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e)) - .into_response(), - } - } - _ => Redirect::to("/user/urls?error=Link not found").into_response(), - } -} - -#[derive(Deserialize)] -pub struct UserPagesQuery { - pub error: Option, - pub page: Option, -} - -#[derive(Deserialize)] -pub struct CreateUserPageForm { - pub title: String, - pub slug: String, - pub code: String, - pub custom_slug: String, - pub state: String, - pub html_content: String, - pub csrf_token: String, -} - -pub async fn user_pages_get( - State(state): State, - jar: CookieJar, - Query(query): Query, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let (pages, total_pages, page, visible_pages) = { - let conn = user_dbs.content.lock().unwrap(); - let total_records = get_landing_page_count(&conn).unwrap_or(0); - let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); - let total_pages = std::cmp::max(1, calculated_total_pages); - let requested_page = query.page.unwrap_or(1); - let current_page = if requested_page == 0 { - 1 - } else { - requested_page - } - .clamp(1, total_pages); - let offset = (current_page - 1) * PAGE_SIZE; - - let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); - let start_page = current_page.saturating_sub(3).max(1); - let end_page = std::cmp::min(total_pages, current_page + 3); - let visible_pages: Vec = (start_page..=end_page).collect(); - (pages, total_pages, current_page, visible_pages) - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::UserPagesTemplate { - admin_username: user.username.clone(), - username: user.username, - pages, - csrf_token, - error: query.error, - current_page: page, - total_pages, - visible_pages, - }; - - template.into_response() -} - -pub async fn user_pages_create( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/user/pages?error=Invalid CSRF token").into_response(); - } - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let mut code = form.custom_slug.trim().to_lowercase(); - if code.is_empty() { - code = form.code.trim().to_lowercase(); - if code.is_empty() { - code = generate_token(2); - } else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters") - .into_response(); - } - } else if !crate::utils::validation::validate_custom_slug(&code) { - return Redirect::to( - "/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _", - ) - .into_response(); - } - - let clean_slug = form.slug.trim().to_lowercase(); - if clean_slug.is_empty() { - return Redirect::to("/user/pages?error=Slug is required").into_response(); - } - - { - let users_conn = state.users_db.lock().unwrap(); - if !crate::db::users::check_quota_limit(&users_conn, user.id, "landings").unwrap_or(false) { - return Redirect::to("/user/pages?error=Quota limit exceeded").into_response(); - } - } - - { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { - return Redirect::to("/user/pages?error=Short code/slug already exists") - .into_response(); - } - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, - &code, - user.id, - "page", - "", - "reserving", - ) { - return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e)) - .into_response(); - } - } - - let res = { - let conn = user_dbs.content.lock().unwrap(); - create_landing_page( - &conn, - &code, - &clean_slug, - &form.title, - &form.html_content, - &form.state, - ) - }; - - match res { - Ok(page) => { - { - let system_conn = state.system_db.lock().unwrap(); - let global_status = if form.state == "published" { - "active" - } else { - "disabled" - }; - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], - ); - } - { - let users_conn = state.users_db.lock().unwrap(); - let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "landings"); - } - let ip = get_client_ip(&headers, connect_info); - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "USER_PAGE_CREATION", - Some("page"), - Some(&page.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/user/pages").into_response() - } - Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); - Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response() - } - } -} - -pub async fn user_pages_delete( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Path(id): Path, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &csrf_token) { - return Redirect::to("/user/pages?error=Invalid CSRF token").into_response(); - } - - let conn = user_dbs.content.lock().unwrap(); - match get_landing_page_by_id(&conn, &id) { - Ok(Some(page)) => { - let _ = crate::db::users::decrement_quota_counter( - &state.users_db.lock().unwrap(), - user.id, - "landings", - ); - match delete_landing_page(&conn, &id) { - Ok(_) => { - let _ = crate::db::users::release_global_slug( - &state.system_db.lock().unwrap(), - &page.code, - user.id, - ); - let ip = get_client_ip(&headers, connect_info); - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "USER_PAGE_DELETION", - Some("page"), - Some(&id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/user/pages").into_response() - } - Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e)) - .into_response(), - } - } - _ => Redirect::to("/user/pages?error=Page not found").into_response(), - } -} - -#[derive(Deserialize)] -pub struct UserSettingsQuery { - pub success: Option, - pub error: Option, -} - -pub async fn user_settings_get( - State(state): State, - jar: CookieJar, - Query(query): Query, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let csrf_token = generate_csrf_token(&session_id); - let template = crate::templates::UserSettingsTemplate { - admin_username: user.username.clone(), - username: user.username, - csrf_token, - success: query.success, - error: query.error, - }; - - template.into_response() -} - -pub async fn user_change_password_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); - } - - if form.current_password.trim().is_empty() || form.new_password.trim().len() < 8 { - return Redirect::to("/user/settings?error=Password must be at least 8 characters") - .into_response(); - } - - let conn = state.users_db.lock().unwrap(); - if !verify_password(&form.current_password, &user.password_hash) { - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "USER_PASSWORD_CHANGE_FAIL", - Some("user"), - Some(&user.id.to_string()), - Some(&get_client_ip(&headers, connect_info)), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - return Redirect::to("/user/settings?error=Incorrect current password").into_response(); - } - - let new_hash = match hash_password(&form.new_password) { - Ok(h) => h, - Err(_) => return Redirect::to("/user/settings?error=Hashing error").into_response(), - }; - - match conn.execute( - "UPDATE users SET password_hash = ?1 WHERE id = ?2;", - params![new_hash, user.id], - ) { - Ok(_) => { - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "USER_PASSWORD_CHANGED", - Some("user"), - Some(&user.id.to_string()), - Some(&get_client_ip(&headers, connect_info)), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/user/settings?success=Password updated successfully").into_response() - } - Err(e) => Redirect::to(&format!( - "/user/settings?error=Failed to update password: {}", - e - )) - .into_response(), - } -} - -pub async fn user_download_backup( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, -) -> Response { - let (user, _) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let ip = get_client_ip(&headers, connect_info); - let user_dir = state - .config - .data_dir - .join("users") - .join(user.id.to_string()); - - let mut buffer = Vec::new(); - let res = { - let enc = GzEncoder::new(&mut buffer, Compression::default()); - let mut tar = Builder::new(enc); - let files = vec!["content.db", "analytics.db", "profile.db"]; - let mut add_err = None; - for f in files { - let path = user_dir.join(f); - if path.exists() { - if let Err(e) = tar.append_path_with_name(&path, f) { - add_err = Some(e); - break; - } - } - } - match add_err { - Some(e) => Err(e), - None => match tar.into_inner().and_then(|encoder| encoder.finish()) { - Ok(_) => Ok(()), - Err(e) => Err(e), - }, - } - }; - - match res { - Ok(_) => { - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "USER_BACKUP", - Some("user"), - Some(&user.id.to_string()), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - let date_str = Utc::now().format("%Y-%m-%d").to_string(); - let filename = format!("user-{}-bzod-backup.tar.gz", date_str); - ( - StatusCode::OK, - [ - ("Content-Type", "application/gzip"), - ( - "Content-Disposition", - &format!("attachment; filename=\"{}\"", filename), - ), - ], - buffer, - ) - .into_response() - } - Err(e) => { - Redirect::to(&format!("/user/settings?error=Backup failed: {}", e)).into_response() - } - } -} - -pub async fn user_restore_backup_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - mut multipart: axum::extract::Multipart, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let ip = get_client_ip(&headers, connect_info); - let mut file_bytes = Vec::new(); - let mut confirm_text = String::new(); - let mut csrf_token = String::new(); - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - if name == "backup_file" { - if let Ok(bytes) = field.bytes().await { - file_bytes = bytes.to_vec(); - } - } else if name == "confirm_text" { - if let Ok(text) = field.text().await { - confirm_text = text.trim().to_string(); - } - } else if name == "csrf_token" { - if let Ok(token) = field.text().await { - csrf_token = token.trim().to_string(); - } - } - } - - if !verify_csrf(&session_id, &csrf_token) { - return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); - } - if confirm_text != "RESTORE" { - return Redirect::to("/user/settings?error=Confirmation text must be exactly 'RESTORE'") - .into_response(); - } - if file_bytes.is_empty() { - return Redirect::to("/user/settings?error=No backup file uploaded").into_response(); - } - - let temp_file_path = - std::env::temp_dir().join(format!("bzod_user_restore_{}.tar.gz", uuid::Uuid::new_v4())); - if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { - return Redirect::to(&format!( - "/user/settings?error=Failed to write temp file: {}", - e - )) - .into_response(); - } - - let user_dir = state - .config - .data_dir - .join("users") - .join(user.id.to_string()); - - let temp_unpack_dir = - std::env::temp_dir().join(format!("bzod_restore_unpack_{}", uuid::Uuid::new_v4())); - if let Err(e) = std::fs::create_dir_all(&temp_unpack_dir) { - let _ = std::fs::remove_file(&temp_file_path); - return Redirect::to(&format!( - "/user/settings?error=Failed to create temp dir: {}", - e - )) - .into_response(); - } - - let restore_res = { - let file = match File::open(&temp_file_path) { - Ok(f) => f, - Err(e) => { - let _ = std::fs::remove_file(&temp_file_path); - let _ = std::fs::remove_dir_all(&temp_unpack_dir); - return Redirect::to(&format!( - "/user/settings?error=Failed to open upload: {}", - e - )) - .into_response(); - } - }; - let tar_gz = GzDecoder::new(file); - let mut archive = tar::Archive::new(tar_gz); - if let Err(e) = archive.unpack(&temp_unpack_dir) { - Err(Box::new(e) as Box) - } else { - // Check for collision using temp content.db - let system_conn = state.system_db.lock().unwrap(); - let temp_content_db = temp_unpack_dir.join("content.db"); - if temp_content_db.exists() { - if let Err(e) = crate::db::users::register_restored_user_slugs( - &system_conn, - user.id, - &temp_content_db, - ) { - Err(e) - } else { - Ok(()) - } - } else { - Err("Backup is missing content.db".into()) - } - } - }; - - let _ = std::fs::remove_file(&temp_file_path); - - match restore_res { - Ok(_) => { - // Success! Copy unpacked files from temp_unpack_dir to user_dir - if let Err(e) = std::fs::create_dir_all(&user_dir) { - let _ = std::fs::remove_dir_all(&temp_unpack_dir); - return Redirect::to(&format!( - "/user/settings?error=Failed to create user directory: {}", - e - )) - .into_response(); - } - - for file_name in &["content.db", "analytics.db", "profile.db"] { - let src = temp_unpack_dir.join(file_name); - if src.exists() { - let dst = user_dir.join(file_name); - if let Err(e) = std::fs::copy(&src, &dst) { - let _ = std::fs::remove_dir_all(&temp_unpack_dir); - return Redirect::to(&format!( - "/user/settings?error=Failed to copy database: {}", - e - )) - .into_response(); - } - } - } - let _ = std::fs::remove_dir_all(&temp_unpack_dir); - - // Reconcile quotas - let users_conn = state.users_db.lock().unwrap(); - if let Ok(content_conn) = rusqlite::Connection::open(user_dir.join("content.db")) { - let _ = - crate::db::users::reconcile_user_quotas(&users_conn, user.id, &content_conn); - } - - let mut pool = state.user_dbs.lock().unwrap(); - pool.remove(&user.id); - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "USER_RESTORE", - Some("user"), - Some(&user.id.to_string()), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/user/settings?success=User database restored successfully") - .into_response() - } - Err(e) => { - let _ = std::fs::remove_dir_all(&temp_unpack_dir); - Redirect::to(&format!("/user/settings?error=Restore failed: {}", e)).into_response() - } - } -} - -// GET /admin/logout -pub async fn logout(State(state): State, jar: CookieJar) -> Response { - if let Ok((_, session_id)) = require_auth(&state, &jar).await { - let conn = state.users_db.lock().unwrap(); - let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]); - } - - let cookie = Cookie::build("bzod_session") - .path("/") - .max_age(time::Duration::ZERO) - .build(); - - let mut response_jar = jar.clone(); - response_jar = response_jar.add(cookie); - - (response_jar, Redirect::to("/admin/login")).into_response() -} - -// GET /admin/dashboard -pub async fn dashboard_get(State(state): State, jar: CookieJar) -> Response { - let (user, _) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let (total_urls, active_links, dead_links) = { - let conn = state.content_db.lock().unwrap(); - get_url_counts(&conn).unwrap_or((0, 0, 0)) - }; - - let total_pages = { - let conn = state.content_db.lock().unwrap(); - get_landing_page_count(&conn).unwrap_or(0) - }; - - let total_clicks = { - let conn = state.analytics_db.lock().unwrap(); - get_total_clicks(&conn).unwrap_or(0) - }; - - let clicks_data = { - let conn = state.analytics_db.lock().unwrap(); - get_clicks_trend(&conn, "url", "all", 30) - .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) - .unwrap_or_default() - }; - - let mut trend_map = std::collections::BTreeMap::new(); - for i in (0..30).rev() { - let date_str = (Utc::now() - chrono::Duration::days(i)) - .format("%Y-%m-%d") - .to_string(); - trend_map.insert(date_str, 0i64); - } - for (d, c) in clicks_data { - trend_map.insert(d, c); - } - let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); - let traffic_chart = generate_line_chart(&formatted_trend); - - let countries_data = { - let conn = state.analytics_db.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "country", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) - .unwrap_or_default() - }; - let countries_chart = generate_bar_chart(&countries_data); - - let referrers_data = { - let conn = state.analytics_db.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "referrer", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) - .unwrap_or_default() - }; - let referrers_chart = generate_bar_chart(&referrers_data); - - let browsers_data = { - let conn = state.analytics_db.lock().unwrap(); - get_metric_rankings(&conn, "url", "all", "browser", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) - .unwrap_or_default() - }; - let browsers_chart = generate_bar_chart(&browsers_data); - - let template = crate::templates::DashboardTemplate { - admin_username: user.username, - total_urls, - total_pages, - total_clicks, - active_links, - dead_links, - traffic_chart, - countries_chart, - browsers_chart, - referrers_chart, - }; - - template.into_response() -} - -// GET /admin/urls -#[derive(Deserialize)] -pub struct UrlsQuery { - pub tag: Option, - pub error: Option, - pub page: Option, -} - -pub async fn urls_get( - State(state): State, - jar: CookieJar, - Query(query): Query, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let (urls, total_pages, page, visible_pages) = { - let conn = state.content_db.lock().unwrap(); - let total_records = if let Some(tag_str) = query.tag.as_deref() { - get_url_count_by_tag(&conn, tag_str).unwrap_or(0) - } else { - get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) - }; - let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); - let total_pages = std::cmp::max(1, calculated_total_pages); - let requested_page = query.page.unwrap_or(1); - let current_page = if requested_page == 0 { - 1 - } else { - requested_page - } - .clamp(1, total_pages); - let offset = (current_page - 1) * PAGE_SIZE; - - let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) - .unwrap_or_default(); - - let start_page = current_page.saturating_sub(3).max(1); - let end_page = std::cmp::min(total_pages, current_page + 3); - let visible_pages: Vec = (start_page..=end_page).collect(); - - (urls, total_pages, current_page, visible_pages) - }; - - let csrf_token = generate_csrf_token(&session_id); - - let proto = if state.config.cookie_secure { - "https" - } else { - "http" - }; - let base_url = state - .config - .base_url - .clone() - .unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port)); - - let template = crate::templates::UrlsTemplate { - admin_username: user.username, - urls, - csrf_token, - error: query.error, - tag_filter: query.tag, - base_url, - current_page: page, - total_pages, - visible_pages, - }; - - template.into_response() -} - -#[derive(Deserialize)] -pub struct CreateUrlForm { - pub destination: String, - pub code: String, - pub custom_slug: String, - pub title: String, - pub description: String, - pub tags: String, - pub csrf_token: String, - pub expires_at: String, - pub password: String, - pub max_access_count: String, - pub utm_source: String, - pub utm_medium: String, - pub utm_campaign: String, -} - -// POST /admin/urls/create -pub async fn urls_create( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - let admin_user_id = user.id.parse::().unwrap_or(1); - - // Custom Slug takes priority if provided - let mut code = form.custom_slug.trim().to_lowercase(); - if code.is_empty() { - code = form.code.trim().to_lowercase(); - if code.is_empty() { - code = generate_token(3); - } else { - if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - return Redirect::to( - "/admin/urls?error=Custom code must be exactly 6 hex characters", - ) - .into_response(); - } - } - } else { - if !crate::utils::validation::validate_custom_slug(&code) { - return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _") - .into_response(); - } - } - - let mut dest = form.destination.trim().to_string(); - if let Ok(mut parsed) = reqwest::Url::parse(&dest) { - let mut has_utm = false; - { - let mut query = parsed.query_pairs_mut(); - if !form.utm_source.trim().is_empty() { - query.append_pair("utm_source", form.utm_source.trim()); - has_utm = true; - } - if !form.utm_medium.trim().is_empty() { - query.append_pair("utm_medium", form.utm_medium.trim()); - has_utm = true; - } - if !form.utm_campaign.trim().is_empty() { - query.append_pair("utm_campaign", form.utm_campaign.trim()); - has_utm = true; - } - } - if has_utm { - dest = parsed.to_string(); - } - } - - let expires_at_opt = if form.expires_at.trim().is_empty() { - None - } else { - let mut rfc = form.expires_at.trim().to_string(); - if rfc.len() == 16 { - rfc.push_str(":00Z"); // convert HTML datetime-local to standard UTC RFC3339 - } - Some(rfc) - }; - - let password_hash_opt = if form.password.trim().is_empty() { - None - } else { - match hash_password(&form.password) { - Ok(h) => Some(h), - Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(), - } - }; - - let max_access_count_opt = if form.max_access_count.trim().is_empty() { - None - } else { - match form.max_access_count.trim().parse::() { - Ok(c) => Some(c), - Err(_) => { - return Redirect::to("/admin/urls?error=Invalid max access count").into_response() - } - } - }; - - let tags_list: Vec = form - .tags - .split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - - let title_opt = if form.title.trim().is_empty() { - None - } else { - Some(form.title.trim()) - }; - let desc_opt = if form.description.trim().is_empty() { - None - } else { - Some(form.description.trim()) - }; - - { - let users_conn = state.users_db.lock().unwrap(); - if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false) - { - return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response(); - } - } - - { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { - return Redirect::to("/admin/urls?error=Short code/slug already exists") - .into_response(); - } - // Always use owner_user_id = 1 for admin content so it resolves via state.content_db - if let Err(e) = - crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving") - { - return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e)) - .into_response(); - } - } - - let res = { - let conn = state.content_db.lock().unwrap(); - crate::db::content::create_url_extended( - &conn, - &code, - &dest, - title_opt, - desc_opt, - &tags_list, - expires_at_opt.as_deref(), - password_hash_opt.as_deref(), - max_access_count_opt, - ) - }; - - match res { - Ok(url) => { - { - let system_conn = state.system_db.lock().unwrap(); - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], - ); - } - { - let users_conn = state.users_db.lock().unwrap(); - let _ = - crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls"); - } - { - let conn = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn, - &state, - &user.username, - "URL_CREATION", - Some("url"), - Some(&url.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - Redirect::to("/admin/urls").into_response() - } - Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); - Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response() - } - } -} - -// POST /admin/urls/delete/:id -pub async fn urls_delete( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Path(id): Path, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &csrf_token) { - return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - - let conn = state.content_db.lock().unwrap(); - match delete_url(&conn, &id) { - Ok(_) => { - { - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "URL_DELETION", - Some("url"), - Some(&id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - Redirect::to("/admin/urls").into_response() - } - Err(e) => { - Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response() - } - } -} - -// GET /admin/pages -#[derive(Deserialize)] -pub struct PagesQuery { - pub error: Option, - pub page: Option, -} - -#[derive(Deserialize)] -pub struct UsersQuery { - pub success: Option, - pub error: Option, -} - -#[derive(Deserialize)] -pub struct CreateUserForm { - pub username: String, - pub password: String, - pub account_type: String, - pub metadata: String, - pub csrf_token: String, -} - -#[derive(Deserialize)] -pub struct UpdateUserStatusForm { - pub status: String, - pub csrf_token: String, -} - -#[derive(Deserialize)] -pub struct UpdateUserTypeForm { - pub account_type: String, - pub csrf_token: String, -} - -#[derive(Deserialize)] -pub struct ResetPasswordForm { - pub new_password: String, - pub csrf_token: String, -} - -#[derive(Deserialize)] -pub struct DeleteUserForm { - pub csrf_token: String, -} - -pub async fn users_get( - State(state): State, - jar: CookieJar, - Query(query): Query, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let users = { - let conn = state.users_db.lock().unwrap(); - crate::db::users::list_users(&conn).unwrap_or_default() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::UsersTemplate { - admin_username: user.username, - users, - csrf_token, - success: query.success, - error: query.error, - }; - - template.into_response() -} - -pub async fn users_create_post( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); - } - - let username = form.username.trim().to_lowercase(); - if username.len() < 3 { - return Redirect::to("/admin/users?error=Username must be at least 3 characters") - .into_response(); - } - if !username - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_') - { - return Redirect::to( - "/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores", - ) - .into_response(); - } - - if form.password.trim().len() < 8 { - return Redirect::to("/admin/users?error=Password must be at least 8 characters") - .into_response(); - } - - let account_type = if form.account_type.trim().is_empty() { - "standard" - } else { - form.account_type.trim() - }; - - let metadata = if form.metadata.trim().is_empty() { - None - } else { - Some(form.metadata.trim()) - }; - - let hash = match hash_password(&form.password) { - Ok(h) => h, - Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(), - }; - - let new_user = { - let conn = state.users_db.lock().unwrap(); - match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) { - Ok(u) => u, - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - return Redirect::to("/admin/users?error=Username already exists").into_response(); - } - Err(e) => { - return Redirect::to(&format!("/admin/users?error=Database error: {}", e)) - .into_response(); - } - } - }; - - if let Err(e) = state.db.init_user_databases(new_user.id) { - return Redirect::to(&format!( - "/admin/users?error=Failed to initialize user databases: {}", - e - )) - .into_response(); - } - - { - let conn = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn, - &state, - &user.username, - "USER_CREATION", - Some("user"), - Some(&new_user.id.to_string()), - None, - None, - ); - } - - Redirect::to("/admin/users?success=User created successfully").into_response() -} - -pub async fn users_update_status_post( - State(state): State, - jar: CookieJar, - Path(id): Path, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); - } - - let status = form.status.trim().to_lowercase(); - if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) { - return Redirect::to("/admin/users?error=Invalid user status").into_response(); - } - - let conn = state.users_db.lock().unwrap(); - match crate::db::users::update_user_status(&conn, id, &status) { - Ok(_) => { - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "USER_STATUS_UPDATE", - Some("user"), - Some(&id.to_string()), - None, - None, - ); - Redirect::to("/admin/users?success=User status updated").into_response() - } - Err(e) => Redirect::to(&format!( - "/admin/users?error=Failed to update status: {}", - e - )) - .into_response(), - } -} - -pub async fn users_update_type_post( - State(state): State, - jar: CookieJar, - Path(id): Path, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); - } - - let account_type = form.account_type.trim().to_lowercase(); - if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str()) - { - return Redirect::to("/admin/users?error=Invalid account type").into_response(); - } - - let conn = state.users_db.lock().unwrap(); - match crate::db::users::update_user_account_type(&conn, id, &account_type) { - Ok(_) => { - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "USER_ACCOUNT_TYPE_UPDATE", - Some("user"), - Some(&id.to_string()), - None, - None, - ); - Redirect::to("/admin/users?success=User account type updated").into_response() - } - Err(e) => Redirect::to(&format!( - "/admin/users?error=Failed to update account type: {}", - e - )) - .into_response(), - } -} - -pub async fn users_reset_password_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Path(id): Path, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); - } - - if form.new_password.trim().len() < 8 { - return Redirect::to("/admin/users?error=Password must be at least 8 characters") - .into_response(); - } - - let hash = match hash_password(&form.new_password) { - Ok(h) => h, - Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(), - }; - - let conn = state.users_db.lock().unwrap(); - match crate::db::users::reset_user_password(&conn, id, &hash) { - Ok(_) => { - let ip = get_client_ip(&headers, connect_info); - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "USER_PASSWORD_RESET", - Some("user"), - Some(&id.to_string()), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/admin/users?success=Password reset successfully").into_response() - } - Err(e) => Redirect::to(&format!( - "/admin/users?error=Failed to reset password: {}", - e - )) - .into_response(), - } -} - -pub async fn users_delete_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Path(id): Path, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); - } - - match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) { - Ok(_) => { - let ip = get_client_ip(&headers, connect_info); - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "USER_DELETION", - Some("user"), - Some(&id.to_string()), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/admin/users?success=User deleted successfully").into_response() - } - Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(), - } -} - -pub async fn pages_get( - State(state): State, - jar: CookieJar, - Query(query): Query, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let (pages, total_pages, page, visible_pages) = { - let conn = state.content_db.lock().unwrap(); - let total_records = get_landing_page_count(&conn).unwrap_or(0); - let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); - let total_pages = std::cmp::max(1, calculated_total_pages); - let requested_page = query.page.unwrap_or(1); - let current_page = if requested_page == 0 { - 1 - } else { - requested_page - } - .clamp(1, total_pages); - let offset = (current_page - 1) * PAGE_SIZE; - - let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); - - let start_page = current_page.saturating_sub(3).max(1); - let end_page = std::cmp::min(total_pages, current_page + 3); - let visible_pages: Vec = (start_page..=end_page).collect(); - - (pages, total_pages, current_page, visible_pages) - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::PagesTemplate { - admin_username: user.username, - pages, - csrf_token, - error: query.error, - current_page: page, - total_pages, - visible_pages, - }; - - template.into_response() -} - -#[derive(Deserialize)] -pub struct CreatePageForm { - pub title: String, - pub slug: String, - pub code: String, - pub custom_slug: String, - pub state: String, - pub html_content: String, - pub csrf_token: String, -} - -// POST /admin/pages/create -pub async fn pages_create( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - let admin_user_id = user.id.parse::().unwrap_or(1); - - // Custom Slug takes priority if provided - let mut code = form.custom_slug.trim().to_lowercase(); - if code.is_empty() { - code = form.code.trim().to_lowercase(); - if code.is_empty() { - code = generate_token(2); - } else { - if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - return Redirect::to( - "/admin/pages?error=Custom code must be exactly 4 hex characters", - ) - .into_response(); - } - } - } else { - if !crate::utils::validation::validate_custom_slug(&code) { - return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _") - .into_response(); - } - } - - let clean_slug = form.slug.trim().to_lowercase(); - if clean_slug.is_empty() { - return Redirect::to("/admin/pages?error=Slug is required").into_response(); - } - - { - let users_conn = state.users_db.lock().unwrap(); - if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings") - .unwrap_or(false) - { - return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response(); - } - } - - { - let system_conn = state.system_db.lock().unwrap(); - if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { - return Redirect::to("/admin/pages?error=Short code already exists").into_response(); - } - // Always use owner_user_id = 1 for admin content so it resolves via state.content_db - if let Err(e) = - crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving") - { - return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e)) - .into_response(); - } - } - - let res = { - let conn = state.content_db.lock().unwrap(); - create_landing_page( - &conn, - &code, - &clean_slug, - &form.title, - &form.html_content, - &form.state, - ) - }; - - match res { - Ok(page) => { - { - let system_conn = state.system_db.lock().unwrap(); - let global_status = if form.state == "published" { - "active" - } else { - "disabled" - }; - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], - ); - } - { - let users_conn = state.users_db.lock().unwrap(); - let _ = crate::db::users::increment_quota_counter( - &users_conn, - admin_user_id, - "landings", - ); - } - { - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "PAGE_CREATION", - Some("page"), - Some(&page.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - Redirect::to("/admin/pages").into_response() - } - Err(e) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); - Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response() - } - } -} - -// POST /admin/pages/delete/:id -pub async fn pages_delete( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Path(id): Path, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &csrf_token) { - return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - - let conn = state.content_db.lock().unwrap(); - match delete_landing_page(&conn, &id) { - Ok(_) => { - { - let conn_admin = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn_admin, - &state, - &user.username, - "PAGE_DELETION", - Some("page"), - Some(&id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - Redirect::to("/admin/pages").into_response() - } - Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e)) - .into_response(), - } -} - -// GET /admin/settings -#[derive(Deserialize)] -pub struct SettingsQuery { - pub success: Option, - pub error: Option, -} - -pub async fn settings_get( - State(state): State, - jar: CookieJar, - Query(query): Query, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let api_keys = { - let conn = state.admin_db.lock().unwrap(); - list_api_keys(&conn, &user.id).unwrap_or_default() - }; - - let data_retention = { - let conn = state.admin_db.lock().unwrap(); - get_config(&conn, "retention_days") - .unwrap_or(None) - .unwrap_or_else(|| { - state - .config - .data_retention_days - .map(|d| d.to_string()) - .unwrap_or_else(|| "unlimited".to_string()) - }) - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::SettingsTemplate { - admin_username: user.username, - api_keys, - data_retention, - csrf_token, - success: query.success, - error: query.error, - }; - - template.into_response() -} - -#[derive(Deserialize)] -pub struct ChangePasswordForm { - pub current_password: String, - pub new_password: String, - pub csrf_token: String, -} - -// POST /admin/settings/password -pub async fn change_password_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - - let conn = state.admin_db.lock().unwrap(); - if !verify_password(&form.current_password, &user.password_hash) { - let _ = write_audit_log( - &conn, - &state, - &user.username, - "PASSWORD_CHANGE_FAIL", - Some("user"), - Some(&user.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - return Redirect::to("/admin/settings?error=Incorrect current password").into_response(); - } - - let new_hash = match hash_password(&form.new_password) { - Ok(h) => h, - Err(_) => return Redirect::to("/admin/settings?error=Hashing error").into_response(), - }; - - let res = conn.execute( - "UPDATE users SET password_hash = ?1 WHERE id = ?2;", - params![new_hash, user.id], - ); - match res { - Ok(_) => { - let _ = write_audit_log( - &conn, - &state, - &user.username, - "PASSWORD_CHANGE_SUCCESS", - Some("user"), - Some(&user.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/admin/settings?success=Password updated successfully").into_response() - } - Err(e) => Redirect::to(&format!( - "/admin/settings?error=Failed to update password: {}", - e - )) - .into_response(), - } -} - -#[derive(Deserialize)] -pub struct RetentionForm { - pub retention: String, - pub csrf_token: String, -} - -// POST /admin/settings/retention -pub async fn change_retention_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - - let conn = state.admin_db.lock().unwrap(); - match set_config(&conn, "retention_days", &form.retention) { - Ok(_) => { - let _ = write_audit_log( - &conn, - &state, - &user.username, - "RETENTION_POLICY_CHANGED", - Some("config"), - Some("retention_days"), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/admin/settings?success=Retention policy saved").into_response() - } - Err(e) => { - Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response() - } - } -} - -// POST /admin/settings/compact -pub async fn compact_db_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, -) -> Response { - let (user, _session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let ip = get_client_ip(&headers, connect_info); - - match state.db_compact() { - Ok(_) => { - let conn = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn, - &state, - &user.username, - "DATABASE_COMPACTION", - Some("system"), - Some("all_dbs"), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/admin/settings?success=Database files compacted successfully") - .into_response() - } - Err(e) => { - Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response() - } - } -} - -// GET /admin/settings/backup -pub async fn download_backup( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, -) -> Response { - let (user, _) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let ip = get_client_ip(&headers, connect_info); - - // Create tar.gz in memory - let mut buffer = Vec::new(); - let res = { - let enc = GzEncoder::new(&mut buffer, Compression::default()); - let mut tar = Builder::new(enc); - - let files = vec![ - ("admin.db", state.config.data_dir.join("admin/admin.db")), - ("system.db", state.config.data_dir.join("admin/system.db")), - ("users.db", state.config.data_dir.join("admin/users.db")), - ( - "content.db", - state.config.data_dir.join("users/1/content.db"), - ), - ( - "analytics.db", - state.config.data_dir.join("users/1/analytics.db"), - ), - ]; - - let mut add_err = None; - let mut manifest_files = Vec::new(); - - for (name, path) in files { - if path.exists() { - if let Err(e) = tar.append_path_with_name(&path, name) { - add_err = Some(e); - break; - } - manifest_files.push(name.to_string()); - } - } - - if add_err.is_none() { - let manifest = serde_json::json!({ - "created_at": chrono::Utc::now().to_rfc3339(), - "type": "legacy_flat_backup", - "files_included": manifest_files, - "note": "Multi-tenant databases flattened for backward compatibility.", - }); - let manifest_str = manifest.to_string(); - let mut header = tar::Header::new_gnu(); - header.set_size(manifest_str.len() as u64); - header.set_cksum(); - if let Err(e) = - tar.append_data(&mut header, "backup_manifest.json", manifest_str.as_bytes()) - { - add_err = Some(e); - } - } - - match add_err { - Some(e) => Err(e), - None => match tar.into_inner().and_then(|encoder| encoder.finish()) { - Ok(_) => Ok(()), - Err(e) => Err(e), - }, - } - }; - - match res { - Ok(_) => { - { - let conn = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn, - &state, - &user.username, - "DATABASE_BACKUP", - Some("system"), - Some("tarball"), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - - let date_str = Utc::now().format("%Y-%m-%d").to_string(); - let filename = format!("{}-bzod-backup.tar.gz", date_str); - - ( - StatusCode::OK, - [ - ("Content-Type", "application/gzip"), - ( - "Content-Disposition", - &format!("attachment; filename=\"{}\"", filename), - ), - ], - buffer, - ) - .into_response() - } - Err(e) => { - Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response() - } - } -} - -#[derive(Deserialize)] -pub struct CreateApiKeyForm { - pub key_name: String, - pub csrf_token: String, -} - -// POST /admin/settings/api-keys/create -pub async fn create_api_key_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - let key_secret = format!("bzo_{}", generate_token(16)); - - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(key_secret.as_bytes()); - let hashed_key = hex::encode(hasher.finalize()); - - let conn = state.admin_db.lock().unwrap(); - match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) { - Ok(api_key) => { - let _ = write_audit_log( - &conn, - &state, - &user.username, - "API_KEY_CREATED", - Some("api_key"), - Some(&api_key.id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to(&format!( - "/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}", - key_secret - )).into_response() - } - Err(e) => { - Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response() - } - } -} - -// POST /admin/settings/api-keys/revoke/:id -pub async fn revoke_api_key_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Path(id): Path, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &csrf_token) { - return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - - let conn = state.admin_db.lock().unwrap(); - match delete_api_key(&conn, &id) { - Ok(_) => { - let _ = write_audit_log( - &conn, - &state, - &user.username, - "API_KEY_REVOKED", - Some("api_key"), - Some(&id), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - Redirect::to("/admin/settings?success=API Token revoked").into_response() - } - Err(e) => Redirect::to(&format!( - "/admin/settings?error=Failed to revoke key: {}", - e - )) - .into_response(), - } -} - -#[derive(Deserialize)] -pub struct BulkQrExportForm { - pub format: String, - pub csrf_token: String, -} - -// POST /admin/settings/bulk-qr -pub async fn bulk_qr_export_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); - } - - let ip = get_client_ip(&headers, connect_info); - - let urls = { - let conn = state.content_db.lock().unwrap(); - crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default() - }; - - if urls.is_empty() { - return Redirect::to("/admin/settings?error=No shortened URLs found to export") - .into_response(); - } - - let proto = if state.config.cookie_secure { - "https" - } else { - "http" - }; - let host_header = headers - .get("host") - .and_then(|h| h.to_str().ok()) - .unwrap_or("localhost:8654"); - let base_url = state - .config - .base_url - .clone() - .unwrap_or_else(|| format!("{}://{}", proto, host_header)); - - match crate::services::bulk::export_qr_zip(&urls, &form.format, &base_url) { - Ok(zip_data) => { - // Write Audit Log - let _ = write_audit_log( - &state.admin_db.lock().unwrap(), - &state, - &user.username, - "BULK_QR_EXPORT", - Some("bulk"), - Some("qr"), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - - Response::builder() - .header("content-type", "application/zip") - .header( - "content-disposition", - "attachment; filename=\"qr_codes.zip\"", - ) - .body(axum::body::Body::from(zip_data)) - .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) - } - Err(e) => { - Redirect::to(&format!("/admin/settings?error=Export failed: {}", e)).into_response() - } - } -} - -// GET /admin/audit -pub async fn audit_get(State(state): State, jar: CookieJar) -> Response { - let (user, _) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let logs = { - let conn = state.system_db.lock().unwrap(); - let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None) - .unwrap_or_default(); - events - .into_iter() - .map(|e| { - let (ip, ua) = if let Some(ref m) = e.metadata { - if m.starts_with("IP: ") { - let parts: Vec<&str> = m.split(", UA: ").collect(); - let ip = parts[0] - .trim_start_matches("IP: ") - .trim_matches('"') - .trim_matches('\'') - .replace("Some(", "") - .replace(")", ""); - let ua = if parts.len() > 1 { - parts[1] - .trim_matches('"') - .trim_matches('\'') - .replace("Some(", "") - .replace(")", "") - } else { - "Unknown".to_string() - }; - (Some(ip), Some(ua)) - } else { - (None, None) - } - } else { - (None, None) - }; - - crate::models::AuditLog { - id: e.id, - timestamp: e.timestamp, - username: e.actor, - action: e.action, - object_type: Some(e.object_type), - object_id: Some(e.object_id), - ip_address: ip, - user_agent: ua, - } - }) - .collect() - }; - - let template = crate::templates::AuditTemplate { - admin_username: user.username, - logs, - }; - - template.into_response() -} - -// GET /admin/status -pub async fn status_get(State(state): State, jar: CookieJar) -> Response { - let (user, _) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let app_status = "Healthy"; - - let db_status = { - let conn_ok = { - let conn = state.admin_db.lock().unwrap(); - get_user_count(&conn).is_ok() - }; - if conn_ok { - format!( - "Operational\n\nDatabase Files:\n{}", - get_db_file_info(&state.config.data_dir) - ) - } else { - "Degraded (Database connections failed)".to_string() - } - }; - - let queue_size = 0; - let memory_usage = get_memory_usage(); - - let uptime_duration = state.start_time.elapsed(); - let uptime = crate::utils::format_duration(uptime_duration); - - let urls = { - let conn = state.content_db.lock().unwrap(); - crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() - }; - - let template = crate::templates::StatusTemplate { - admin_username: user.username, - app_status, - db_status, - queue_size, - memory_usage, - uptime, - version: "0.1.0", - git_commit: "unknown", - urls, - }; - - template.into_response() -} - -// GET /user/audit -pub async fn user_audit_get(State(state): State, jar: CookieJar) -> Response { - let (user, _) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let logs = { - let conn = state.system_db.lock().unwrap(); - let events = - crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None) - .unwrap_or_default(); - events - .into_iter() - .map(|e| { - let (ip, ua) = if let Some(ref m) = e.metadata { - if m.starts_with("IP: ") { - let parts: Vec<&str> = m.split(", UA: ").collect(); - let ip = parts[0] - .trim_start_matches("IP: ") - .trim_matches('"') - .trim_matches('\'') - .replace("Some(", "") - .replace(")", ""); - let ua = if parts.len() > 1 { - parts[1] - .trim_matches('"') - .trim_matches('\'') - .replace("Some(", "") - .replace(")", "") - } else { - "Unknown".to_string() - }; - (Some(ip), Some(ua)) - } else { - (None, None) - } - } else { - (None, None) - }; - - crate::models::AuditLog { - id: e.id, - timestamp: e.timestamp, - username: e.actor, - action: e.action, - object_type: Some(e.object_type), - object_id: Some(e.object_id), - ip_address: ip, - user_agent: ua, - } - }) - .collect() - }; - - let template = crate::templates::UserAuditTemplate { - admin_username: user.username, - logs, - }; - - template.into_response() -} - -// GET /user/status -pub async fn user_status_get(State(state): State, jar: CookieJar) -> Response { - let (user, _) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let app_status = "Healthy"; - - let db_status = { - let conn_ok = { - let conn = user_dbs.content.lock().unwrap(); - get_url_counts(&conn).is_ok() - }; - if conn_ok { - "Operational".to_string() - } else { - "Degraded (Database connection failed)".to_string() - } - }; - - let queue_size = 0; - let memory_usage = get_memory_usage(); - - let uptime_duration = state.start_time.elapsed(); - let uptime = crate::utils::format_duration(uptime_duration); - - let urls = { - let conn = user_dbs.content.lock().unwrap(); - crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() - }; - - let template = crate::templates::UserStatusTemplate { - admin_username: user.username, - app_status, - db_status, - queue_size, - memory_usage, - uptime, - version: "0.1.0", - git_commit: "unknown", - urls, - }; - - template.into_response() -} - -// POST /admin/settings/restore -pub async fn restore_backup_post( - State(state): State, - jar: CookieJar, - headers: HeaderMap, - connect_info: Option>, - mut multipart: axum::extract::Multipart, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let ip = get_client_ip(&headers, connect_info); - let mut file_bytes = Vec::new(); - let mut confirm_text = String::new(); - let mut csrf_token = String::new(); - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - if name == "backup_file" { - if let Ok(bytes) = field.bytes().await { - file_bytes = bytes.to_vec(); - } - } else if name == "confirm_text" { - if let Ok(text) = field.text().await { - confirm_text = text.trim().to_string(); - } - } else if name == "csrf_token" { - if let Ok(token) = field.text().await { - csrf_token = token.trim().to_string(); - } - } - } - - if !verify_csrf(&session_id, &csrf_token) { - return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); - } - - if confirm_text != "RESTORE" { - return Redirect::to("/admin/settings?error=Confirmation text must be exactly 'RESTORE'") - .into_response(); - } - - if file_bytes.is_empty() { - return Redirect::to("/admin/settings?error=No backup file uploaded").into_response(); - } - - // Save uploaded archive to a temporary file - let temp_file_path = - std::env::temp_dir().join(format!("bzod_restore_{}.tar.gz", uuid::Uuid::new_v4())); - if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { - return Redirect::to(&format!( - "/admin/settings?error=Failed to write temp file: {}", - e - )) - .into_response(); - } - - // Log RESTORE_INITIATED audit event before restore - { - let conn = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn, - &state, - &user.username, - "RESTORE_INITIATED", - Some("system"), - Some("tarball"), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - - // Call the perform_restore engine inside closed connection blocks - let restore_res = { - // Temporarily suspend access to active SQLite connections - let mut admin_conn = state.admin_db.lock().unwrap(); - let mut content_conn = state.content_db.lock().unwrap(); - let mut analytics_conn = state.analytics_db.lock().unwrap(); - let mut system_conn = state.system_db.lock().unwrap(); - - // 1. Close current connections by replacing them with dummy in-memory DBs - *admin_conn = match rusqlite::Connection::open_in_memory() { - Ok(c) => c, - Err(e) => { - return Redirect::to(&format!( - "/admin/settings?error=Failed to open temp in-memory DB: {}", - e - )) - .into_response() - } - }; - *content_conn = match rusqlite::Connection::open_in_memory() { - Ok(c) => c, - Err(e) => { - return Redirect::to(&format!( - "/admin/settings?error=Failed to open temp in-memory DB: {}", - e - )) - .into_response() - } - }; - *analytics_conn = match rusqlite::Connection::open_in_memory() { - Ok(c) => c, - Err(e) => { - return Redirect::to(&format!( - "/admin/settings?error=Failed to open temp in-memory DB: {}", - e - )) - .into_response() - } - }; - *system_conn = match rusqlite::Connection::open_in_memory() { - Ok(c) => c, - Err(e) => { - return Redirect::to(&format!( - "/admin/settings?error=Failed to open temp in-memory DB: {}", - e - )) - .into_response() - } - }; - - // 2. Perform restore unpacking/validation - let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir); - - // 3. Post-Restore Path Normalization (Move flat files to multi-tenant structure) - let admin_dir = state.config.data_dir.join("admin"); - let users_1_dir = state.config.data_dir.join("users").join("1"); - let _ = std::fs::create_dir_all(&admin_dir); - let _ = std::fs::create_dir_all(&users_1_dir); - - let admin_files = vec![ - "admin.db", - "admin.db-wal", - "admin.db-shm", - "system.db", - "system.db-wal", - "system.db-shm", - "users.db", - "users.db-wal", - "users.db-shm", - ]; - for f in admin_files { - let src = state.config.data_dir.join(f); - if src.exists() { - let _ = std::fs::rename(&src, admin_dir.join(f)); - } - } - - let content_files = vec![ - "content.db", - "content.db-wal", - "content.db-shm", - "analytics.db", - "analytics.db-wal", - "analytics.db-shm", - ]; - for f in content_files { - let src = state.config.data_dir.join(f); - if src.exists() { - let _ = std::fs::rename(&src, users_1_dir.join(f)); - } - } - - // 4. Reinitialize database connections using correct multi-tenant paths - let new_admin = rusqlite::Connection::open(state.config.data_dir.join("admin/admin.db")); - let new_system = rusqlite::Connection::open(state.config.data_dir.join("admin/system.db")); - - let new_users = rusqlite::Connection::open(state.config.data_dir.join("admin/users.db")); - - let new_content = - rusqlite::Connection::open(state.config.data_dir.join("users/1/content.db")); - let new_analytics = - rusqlite::Connection::open(state.config.data_dir.join("users/1/analytics.db")); - - match (new_admin, new_content, new_analytics, new_system, new_users) { - (Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => { - let _ = crate::db::sqlite::enable_wal(&adm, "admin"); - let _ = crate::db::sqlite::enable_wal(&cnt, "content"); - let _ = crate::db::sqlite::enable_wal(&any, "analytics"); - let _ = crate::db::sqlite::enable_wal(&sys, "system"); - let _ = crate::db::sqlite::enable_wal(&usr, "users"); - - let _ = crate::db::sqlite::enable_foreign_keys(&adm, "admin"); - let _ = crate::db::sqlite::enable_foreign_keys(&cnt, "content"); - let _ = crate::db::sqlite::enable_foreign_keys(&any, "analytics"); - let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system"); - let _ = crate::db::sqlite::enable_foreign_keys(&usr, "users"); - - *admin_conn = adm; - *content_conn = cnt; - *analytics_conn = any; - *system_conn = sys; - *state.db.users.lock().unwrap() = usr; - } - _ => { - return Redirect::to("/admin/settings?error=Failed to reopen restored databases") - .into_response(); - } - } - - res - }; - - let _ = std::fs::remove_file(&temp_file_path); - - match restore_res { - Ok(_) => { - // Write database restore success log to newly restored admin db - { - let conn = state.admin_db.lock().unwrap(); - let _ = write_audit_log( - &conn, - &state, - &user.username, - "DATABASE_RESTORE", - Some("system"), - Some("tarball"), - Some(&ip), - headers.get("user-agent").and_then(|h| h.to_str().ok()), - ); - } - - // Run doctor check to verify integrity after restore (spawn in background since we can't easily await here) - tracing::info!("Running post-restore diagnostics..."); - let config_clone = state.config.clone(); - tokio::spawn(async move { - let _ = crate::cli::doctor::run(None, config_clone).await; - }); - - Redirect::to("/admin/login").into_response() - } - Err(e) => { - Redirect::to(&format!("/admin/settings?error=Restore failed: {}", e)).into_response() - } - } -} - -#[derive(Deserialize)] -pub struct AnalyticsQuery { - pub analytics_page: Option, - pub date_from: Option, - pub date_to: Option, -} - -fn validate_date_filters( - date_from: Option<&str>, - date_to: Option<&str>, -) -> Result<(Option, Option), StatusCode> { - let from_parsed = match date_from { - Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") { - Ok(d) => Some(d), - Err(_) => return Err(StatusCode::BAD_REQUEST), - }, - _ => None, - }; - let to_parsed = match date_to { - Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { - Ok(d) => Some(d), - Err(_) => return Err(StatusCode::BAD_REQUEST), - }, - _ => None, - }; - if let (Some(f), Some(t)) = (from_parsed, to_parsed) { - if f > t { - return Err(StatusCode::BAD_REQUEST); - } - } - Ok(( - from_parsed.map(|d| d.format("%Y-%m-%d").to_string()), - to_parsed.map(|d| d.format("%Y-%m-%d").to_string()), - )) -} - -fn escape_csv_field(field: &str) -> String { - let needs_escaping = - field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r'); - if needs_escaping { - let escaped = field.replace('"', "\"\""); - format!("\"{}\"", escaped) - } else { - field.to_string() - } -} - -struct DbExportStream { - receiver: tokio::sync::mpsc::Receiver>, -} - -impl futures_util::stream::Stream for DbExportStream { - type Item = Result; - - fn poll_next( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - self.receiver.poll_recv(cx) - } -} - -async fn perform_csv_export( - state: AppState, - target_type: &'static str, - id: String, - date_from: Option, - date_to: Option, -) -> Response { - let (clean_date_from, clean_date_to) = - match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; - - let target_exists = { - let conn = state.content_db.lock().unwrap(); - if target_type == "url" { - get_url_by_id(&conn, &id) - .map(|u| u.is_some()) - .unwrap_or(false) - } else { - get_landing_page_by_id(&conn, &id) - .map(|p| p.is_some()) - .unwrap_or(false) - } - }; - if !target_exists { - return (StatusCode::NOT_FOUND, "Target not found").into_response(); - } - - let count = { - let conn = state.analytics_db.lock().unwrap(); - get_target_visit_total_filtered( - &conn, - target_type, - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or(0) - }; - - let (has_utm_source, has_utm_campaign) = { - let conn = state.analytics_db.lock().unwrap(); - let cols = get_visits_schema_columns(&conn).unwrap_or_default(); - (cols.contains("utm_source"), cols.contains("utm_campaign")) - }; - - let (tx, rx) = - tokio::sync::mpsc::channel::>(32); - let analytics_db = state.analytics_db.clone(); - let target_id = id.clone(); - - tokio::task::spawn_blocking(move || { - let conn = analytics_db.lock().unwrap(); - - let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string(); - if has_utm_source { - header.push_str(",UTM Source"); - } - if has_utm_campaign { - header.push_str(",UTM Campaign"); - } - header.push('\n'); - - if tx - .blocking_send(Ok(axum::body::Bytes::from(header))) - .is_err() - { - return; - } - - let select_fields = if has_utm_source && has_utm_campaign { - "timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign" - } else if has_utm_source { - "timestamp, ip_address, country, referer, user_agent, utm_source" - } else if has_utm_campaign { - "timestamp, ip_address, country, referer, user_agent, utm_campaign" - } else { - "timestamp, ip_address, country, referer, user_agent" - }; - - let mut sql = format!( - "SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2", - select_fields - ); - let mut params: Vec> = - vec![Box::new(target_type.to_string()), Box::new(target_id)]; - - if let Some(df) = clean_date_from.as_deref() { - sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); - params.push(Box::new(format!("{}T00:00:00Z", df))); - } - - if let Some(dt) = clean_date_to.as_deref() { - if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { - let next_day = parsed_date + chrono::Duration::days(1); - sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); - params.push(Box::new(format!( - "{}T00:00:00Z", - next_day.format("%Y-%m-%d") - ))); - } - } - - sql.push_str(" ORDER BY timestamp DESC, id DESC"); - - let mut stmt = match conn.prepare(&sql) { - Ok(s) => s, - Err(_) => return, - }; - - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) { - Ok(r) => r, - Err(_) => return, - }; - - let mut csv_buffer = String::new(); - - while let Ok(Some(row)) = rows.next() { - let timestamp: String = row.get(0).unwrap_or_default(); - let ip_address: String = row.get(1).unwrap_or_default(); - let country: String = row.get(2).unwrap_or_default(); - let referer: String = row.get(3).unwrap_or_default(); - let user_agent: String = row.get(4).unwrap_or_default(); - - let (browser, _, _) = parse_ua(&user_agent); - let referrer = clean_referrer(&referer); - let country_display = if country.is_empty() { - "Unknown".to_string() - } else { - country - }; - - let mut line = format!( - "{},{},{},{},{},{}", - escape_csv_field(×tamp), - escape_csv_field(&ip_address), - escape_csv_field(&country_display), - escape_csv_field(&referrer), - escape_csv_field(&browser), - escape_csv_field(&user_agent) - ); - - let mut col_idx = 5; - if has_utm_source { - let utm_src: String = row.get(col_idx).unwrap_or_default(); - line.push_str(&format!(",{}", escape_csv_field(&utm_src))); - col_idx += 1; - } - if has_utm_campaign { - let utm_camp: String = row.get(col_idx).unwrap_or_default(); - line.push_str(&format!(",{}", escape_csv_field(&utm_camp))); - } - line.push('\n'); - - csv_buffer.push_str(&line); - if csv_buffer.len() >= 8192 { - let bytes = axum::body::Bytes::from(csv_buffer); - if tx.blocking_send(Ok(bytes)).is_err() { - return; - } - csv_buffer = String::new(); - } - } - - if !csv_buffer.is_empty() { - let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer))); - } - }); - - let stream = DbExportStream { receiver: rx }; - let filename = if target_type == "url" { - format!("url_{}_analytics.csv", id) - } else { - format!("page_{}_analytics.csv", id) - }; - - ( - StatusCode::OK, - [ - ("Content-Type", "text/csv"), - ( - "Content-Disposition", - &format!("attachment; filename=\"{}\"", filename), - ), - ("X-BZOD-Export-Records", &count.to_string()), - ], - axum::body::Body::from_stream(stream), - ) - .into_response() -} - -async fn perform_json_export( - state: AppState, - target_type: &'static str, - id: String, - date_from: Option, - date_to: Option, -) -> Response { - let (clean_date_from, clean_date_to) = - match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; - - let target_exists = { - let conn = state.content_db.lock().unwrap(); - if target_type == "url" { - get_url_by_id(&conn, &id) - .map(|u| u.is_some()) - .unwrap_or(false) - } else { - get_landing_page_by_id(&conn, &id) - .map(|p| p.is_some()) - .unwrap_or(false) - } - }; - if !target_exists { - return (StatusCode::NOT_FOUND, "Target not found").into_response(); - } - - let count = { - let conn = state.analytics_db.lock().unwrap(); - get_target_visit_total_filtered( - &conn, - target_type, - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or(0) - }; - - if count > MAX_JSON_EXPORT_ROWS as i64 { - return StatusCode::PAYLOAD_TOO_LARGE.into_response(); - } - - let visits_raw = { - let conn = state.analytics_db.lock().unwrap(); - match get_target_visits_all_in_memory( - &conn, - target_type, - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) { - Ok(v) => v, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - } - }; - - #[derive(serde::Serialize)] - struct JsonExportRow { - timestamp: String, - ip_address: String, - country: String, - referrer: String, - browser: String, - user_agent: String, - } - - let export_rows: Vec = visits_raw - .into_iter() - .map(|r| { - let (browser, _, _) = parse_ua(&r.user_agent); - let referrer = clean_referrer(&r.referer); - let country_display = if r.country.is_empty() { - "Unknown".to_string() - } else { - r.country - }; - JsonExportRow { - timestamp: r.timestamp, - ip_address: r.ip_address, - country: country_display, - referrer, - browser, - user_agent: r.user_agent, - } - }) - .collect(); - - let body_str = match serde_json::to_string(&export_rows) { - Ok(s) => s, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response() - } - }; - - let filename = if target_type == "url" { - format!("url_{}_analytics.json", id) - } else { - format!("page_{}_analytics.json", id) - }; - - ( - StatusCode::OK, - [ - ("Content-Type", "application/json"), - ( - "Content-Disposition", - &format!("attachment; filename=\"{}\"", filename), - ), - ("X-BZOD-Export-Records", &count.to_string()), - ], - body_str, - ) - .into_response() -} - -// GET /admin/analytics/url/:id -pub async fn url_analytics_get( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - let (user, _) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let url = { - let conn = state.content_db.lock().unwrap(); - match get_url_by_id(&conn, &id) { - Ok(Some(u)) => u, - Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - } - }; - - let conn = state.analytics_db.lock().unwrap(); - - let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); - let has_utm_source = schema_cols.contains("utm_source"); - let has_utm_campaign = schema_cols.contains("utm_campaign"); - if has_utm_source || has_utm_campaign { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "UTM mapping verification failed", - ) - .into_response(); - } - - let (clean_date_from, clean_date_to) = - match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; - - let total_clicks = get_target_visit_total_filtered( - &conn, - "url", - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or(0); - - let unique_visitors = get_target_unique_visitors(&conn, "url", &id).unwrap_or(0); - let qr_scans = crate::db::qr::get_qr_scan_count(&conn, &id).unwrap_or(0); - let direct_clicks = (total_clicks - qr_scans).max(0); - - let clicks_data = get_clicks_trend(&conn, "url", &id, 30) - .or_else(|_| get_clicks_trend_raw(&conn, "url", &id, 30)) - .unwrap_or_default(); - let mut trend_map = std::collections::BTreeMap::new(); - for i in (0..30).rev() { - let date_str = (Utc::now() - chrono::Duration::days(i)) - .format("%Y-%m-%d") - .to_string(); - trend_map.insert(date_str, 0i64); - } - for (d, c) in clicks_data { - trend_map.insert(d, c); - } - let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); - let traffic_chart = generate_line_chart(&formatted_trend); - - let monthly_data = get_monthly_clicks_trend(&conn, "url", &id, 12).unwrap_or_default(); - let monthly_chart = generate_line_chart(&monthly_data); - - let countries_data = get_metric_rankings(&conn, "url", &id, "country", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "country", 5)) - .unwrap_or_default(); - let countries_chart = generate_bar_chart(&countries_data); - - let referrers_data = get_metric_rankings(&conn, "url", &id, "referrer", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "referrer", 5)) - .unwrap_or_default(); - let referrers_chart = generate_bar_chart(&referrers_data); - - let browsers_data = get_metric_rankings(&conn, "url", &id, "browser", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "browser", 5)) - .unwrap_or_default(); - let browsers_chart = generate_bar_chart(&browsers_data); - - let calculated_total_pages = (total_clicks as usize).div_ceil(ANALYTICS_PAGE_SIZE); - let total_pages = std::cmp::max(1, calculated_total_pages); - let requested_page = query.analytics_page.unwrap_or(1); - let current_page = if requested_page == 0 { - 1 - } else { - requested_page - } - .clamp(1, total_pages); - let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; - - let visits_raw = get_target_visits_paginated( - &conn, - "url", - &id, - ANALYTICS_PAGE_SIZE as i64, - offset as i64, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or_default(); - - let visits: Vec = visits_raw - .into_iter() - .enumerate() - .map(|(idx, r)| { - let (browser, _, _) = parse_ua(&r.user_agent); - let referrer = clean_referrer(&r.referer); - let sr = offset + idx + 1; - crate::templates::VisitorLogEntry { - sr, - timestamp: r.timestamp, - ip_address: r.ip_address, - country: if r.country.is_empty() { - "Unknown".to_string() - } else { - r.country - }, - referrer, - browser, - user_agent: r.user_agent, - utm_source: "-".to_string(), - utm_campaign: "-".to_string(), - } - }) - .collect(); - - let start_page = current_page.saturating_sub(3).max(1); - let end_page = std::cmp::min(total_pages, current_page + 3); - let visible_pages: Vec = (start_page..=end_page).collect(); - - let page_start = if total_clicks == 0 { 0 } else { offset + 1 }; - let page_end = if total_clicks == 0 { - 0 - } else { - std::cmp::min(total_clicks as usize, offset + ANALYTICS_PAGE_SIZE) - }; - - let template = crate::templates::UrlAnalyticsTemplate { - admin_username: user.username, - url, - total_clicks, - unique_visitors, - qr_scans, - direct_clicks, - traffic_chart, - monthly_chart, - countries_chart, - referrers_chart, - browsers_chart, - visits, - current_page, - total_pages, - visible_pages, - total_records: total_clicks, - page_start, - page_end, - date_from: clean_date_from, - date_to: clean_date_to, - is_admin: true, - }; - - template.into_response() -} - -// GET /admin/analytics/page/:id -pub async fn page_analytics_get( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - let (user, _) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let page = { - let conn = state.content_db.lock().unwrap(); - match get_landing_page_by_id(&conn, &id) { - Ok(Some(p)) => p, - Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(), - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - } - }; - - let conn = state.analytics_db.lock().unwrap(); - - let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); - let has_utm_source = schema_cols.contains("utm_source"); - let has_utm_campaign = schema_cols.contains("utm_campaign"); - if has_utm_source || has_utm_campaign { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "UTM mapping verification failed", - ) - .into_response(); - } - - let (clean_date_from, clean_date_to) = - match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; - - let total_views = get_target_visit_total_filtered( - &conn, - "page", - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or(0); - - let unique_visitors = get_target_unique_visitors(&conn, "page", &id).unwrap_or(0); - - let clicks_data = get_clicks_trend(&conn, "page", &id, 30) - .or_else(|_| get_clicks_trend_raw(&conn, "page", &id, 30)) - .unwrap_or_default(); - let mut trend_map = std::collections::BTreeMap::new(); - for i in (0..30).rev() { - let date_str = (Utc::now() - chrono::Duration::days(i)) - .format("%Y-%m-%d") - .to_string(); - trend_map.insert(date_str, 0i64); - } - for (d, c) in clicks_data { - trend_map.insert(d, c); - } - let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); - let traffic_chart = generate_line_chart(&formatted_trend); - - let monthly_data = get_monthly_clicks_trend(&conn, "page", &id, 12).unwrap_or_default(); - let monthly_chart = generate_line_chart(&monthly_data); - - let countries_data = get_metric_rankings(&conn, "page", &id, "country", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "country", 5)) - .unwrap_or_default(); - let countries_chart = generate_bar_chart(&countries_data); - - let referrers_data = get_metric_rankings(&conn, "page", &id, "referrer", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "referrer", 5)) - .unwrap_or_default(); - let referrers_chart = generate_bar_chart(&referrers_data); - - let calculated_total_pages = (total_views as usize).div_ceil(ANALYTICS_PAGE_SIZE); - let total_pages = std::cmp::max(1, calculated_total_pages); - let requested_page = query.analytics_page.unwrap_or(1); - let current_page = if requested_page == 0 { - 1 - } else { - requested_page - } - .clamp(1, total_pages); - let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; - - let visits_raw = get_target_visits_paginated( - &conn, - "page", - &id, - ANALYTICS_PAGE_SIZE as i64, - offset as i64, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or_default(); - - let visits: Vec = visits_raw - .into_iter() - .enumerate() - .map(|(idx, r)| { - let (browser, _, _) = parse_ua(&r.user_agent); - let referrer = clean_referrer(&r.referer); - let sr = offset + idx + 1; - crate::templates::VisitorLogEntry { - sr, - timestamp: r.timestamp, - ip_address: r.ip_address, - country: if r.country.is_empty() { - "Unknown".to_string() - } else { - r.country - }, - referrer, - browser, - user_agent: r.user_agent, - utm_source: "-".to_string(), - utm_campaign: "-".to_string(), - } - }) - .collect(); - - let start_page = current_page.saturating_sub(3).max(1); - let end_page = std::cmp::min(total_pages, current_page + 3); - let visible_pages: Vec = (start_page..=end_page).collect(); - - let page_start = if total_views == 0 { 0 } else { offset + 1 }; - let page_end = if total_views == 0 { - 0 - } else { - std::cmp::min(total_views as usize, offset + ANALYTICS_PAGE_SIZE) - }; - - let template = crate::templates::PageAnalyticsTemplate { - admin_username: user.username, - page, - total_views, - unique_visitors, - traffic_chart, - monthly_chart, - countries_chart, - referrers_chart, - visits, - current_page, - total_pages, - visible_pages, - total_records: total_views, - page_start, - page_end, - date_from: clean_date_from, - date_to: clean_date_to, - is_admin: true, - }; - - template.into_response() -} - -pub async fn url_analytics_csv_export( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - if require_auth(&state, &jar).await.is_err() { - return StatusCode::UNAUTHORIZED.into_response(); - } - perform_csv_export(state, "url", id, query.date_from, query.date_to).await -} - -pub async fn url_analytics_json_export( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - if require_auth(&state, &jar).await.is_err() { - return StatusCode::UNAUTHORIZED.into_response(); - } - perform_json_export(state, "url", id, query.date_from, query.date_to).await -} - -pub async fn page_analytics_csv_export( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - if require_auth(&state, &jar).await.is_err() { - return StatusCode::UNAUTHORIZED.into_response(); - } - perform_csv_export(state, "page", id, query.date_from, query.date_to).await -} - -pub async fn page_analytics_json_export( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - if require_auth(&state, &jar).await.is_err() { - return StatusCode::UNAUTHORIZED.into_response(); - } - perform_json_export(state, "page", id, query.date_from, query.date_to).await -} - -// --------------------------------------------------------------------------- -// GA Hardening UI Handlers and Structs -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct UserDetailStats { - pub max_urls: i64, - pub max_landings: i64, - pub max_api_tokens: i64, - pub max_storage_mb: i64, - pub current_urls: i64, - pub current_landings: i64, - pub current_api_tokens: i64, - pub current_storage_mb: i64, - pub url_pct: i64, - pub landing_pct: i64, - pub token_pct: i64, - pub storage_pct: i64, - pub total_visits: i64, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct GlobalSlugRow { - pub slug: String, - pub owner_user_id: i64, - pub target_type: String, - pub target_id: String, - pub created_at: String, - pub updated_at: String, - pub status: String, - pub deleted_at: Option, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct ModerationLogEntry { - pub id: String, - pub timestamp: String, - pub admin_username: String, - pub target_user_id: i64, - pub target_username: Option, - pub resource_type: String, - pub resource_identifier: String, - pub action: String, - pub severity: String, - pub reason: String, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct SlugHistoryRow { - pub id: i64, - pub slug: String, - pub old_owner_user_id: Option, - pub new_owner_user_id: Option, - pub action: String, - pub timestamp: String, - pub admin_username: Option, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct JobHistoryRow { - pub id: String, - pub job_name: String, - pub status: String, - pub started_at: String, - pub finished_at: Option, - pub error_message: Option, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct HealthCheckRow { - pub id: String, - pub object_type: String, - pub object_id: String, - pub checked_at: String, - pub status_code: Option, - pub error_message: Option, - pub is_healthy: i64, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct BackupFileRow { - pub filename: String, - pub size_str: String, - pub created_str: String, -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct BackupHistoryRow { - pub id: String, - pub backup_path: String, - pub status: String, - pub created_at: String, - pub size_bytes: i64, - pub error_message: Option, -} - -// Helpers -fn format_size(bytes: u64) -> String { - if bytes < 1024 { - format!("{} B", bytes) - } else if bytes < 1024 * 1024 { - format!("{:.2} KB", bytes as f64 / 1024.0) - } else { - format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0)) - } -} - -fn get_dir_size(dir: &std::path::Path) -> std::io::Result { - let mut total = 0; - if dir.is_dir() { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - total += get_dir_size(&path)?; - } else { - total += entry.metadata()?.len(); - } - } - } - Ok(total) -} - -pub fn get_user_detail_stats( - state: &AppState, - user_id: i64, -) -> Result> { - use rusqlite::OptionalExtension; - let quotas = { - let conn = state.users_db.lock().unwrap(); - conn.query_row( - "SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb - FROM quotas WHERE user_id = ?1;", - [user_id], - |row| Ok(crate::models::UserQuotas { - user_id, - max_urls: row.get(0)?, - max_landings: row.get(1)?, - max_api_tokens: row.get(2)?, - max_storage_mb: row.get(3)?, - current_urls: row.get(4)?, - current_landings: row.get(5)?, - current_api_tokens: row.get(6)?, - current_storage_mb: row.get(7)?, - }) - ).optional()? - }; - - let quotas = quotas.unwrap_or(crate::models::UserQuotas { - user_id, - max_urls: 100, - max_landings: 10, - max_api_tokens: 5, - max_storage_mb: 100, - current_urls: 0, - current_landings: 0, - current_api_tokens: 0, - current_storage_mb: 0, - }); - - let total_visits = { - if let Ok(dbs) = state.get_user_dbs(user_id) { - let conn = dbs.analytics.lock().unwrap(); - conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap_or(0) - } else { - 0 - } - }; - - let url_pct = if quotas.max_urls > 0 { - (quotas.current_urls * 100) / quotas.max_urls - } else { - 0 - }; - let landing_pct = if quotas.max_landings > 0 { - (quotas.current_landings * 100) / quotas.max_landings - } else { - 0 - }; - let token_pct = if quotas.max_api_tokens > 0 { - (quotas.current_api_tokens * 100) / quotas.max_api_tokens - } else { - 0 - }; - let storage_pct = if quotas.max_storage_mb > 0 { - (quotas.current_storage_mb * 100) / quotas.max_storage_mb - } else { - 0 - }; - - Ok(UserDetailStats { - max_urls: quotas.max_urls, - max_landings: quotas.max_landings, - max_api_tokens: quotas.max_api_tokens, - max_storage_mb: quotas.max_storage_mb, - current_urls: quotas.current_urls, - current_landings: quotas.current_landings, - current_api_tokens: quotas.current_api_tokens, - current_storage_mb: quotas.current_storage_mb, - url_pct, - landing_pct, - token_pct, - storage_pct, - total_visits, - }) -} - -// GET /admin/users/new -pub async fn users_new_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::UsersNewTemplate { - admin_username: user.username, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -// GET /admin/users/:id -pub async fn user_detail_get( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let target_user = { - let conn = state.users_db.lock().unwrap(); - let u_res = conn.query_row( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata - FROM users WHERE id = ?1;", - [id], - |row| Ok(crate::models::TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - ); - match u_res { - Ok(u) => u, - Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), - } - }; - - let stats = match get_user_detail_stats(&state, id) { - Ok(s) => s, - Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(), - }; - - let sessions = { - let conn = state.users_db.lock().unwrap(); - let mut stmt = conn - .prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;") - .unwrap(); - let rows = stmt - .query_map([id], |row| { - Ok(crate::models::UserSession { - id: row.get(0)?, - user_id: row.get(1)?, - expires_at: row.get(2)?, - created_at: row.get(3)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let tokens = { - let conn = state.users_db.lock().unwrap(); - let mut stmt = conn - .prepare( - "SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;", - ) - .unwrap(); - let rows = stmt - .query_map([id], |row| { - Ok(crate::models::UserApiToken { - id: row.get(0)?, - user_id: row.get(1)?, - token_hash: row.get(2)?, - created_at: row.get(3)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::UserDetailTemplate { - admin_username: user.username, - target_user, - stats, - sessions, - tokens, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -// GET /admin/users/:id/edit -pub async fn user_edit_get( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let target_user = { - let conn = state.users_db.lock().unwrap(); - let u_res = conn.query_row( - "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata - FROM users WHERE id = ?1;", - [id], - |row| Ok(crate::models::TenantUser { - id: row.get(0)?, - username: row.get(1)?, - password_hash: row.get(2)?, - status: row.get(3)?, - created_at: row.get(4)?, - last_login: row.get(5)?, - account_type: row.get(6)?, - organization_id: row.get(7)?, - metadata: row.get(8)?, - }) - ); - match u_res { - Ok(u) => u, - Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), - } - }; - - let quotas = { - let conn = state.users_db.lock().unwrap(); - conn.query_row( - "SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb - FROM quotas WHERE user_id = ?1;", - [id], - |row| Ok(crate::models::UserQuotas { - user_id: id, - max_urls: row.get(0)?, - max_landings: row.get(1)?, - max_api_tokens: row.get(2)?, - max_storage_mb: row.get(3)?, - current_urls: row.get(4)?, - current_landings: row.get(5)?, - current_api_tokens: row.get(6)?, - current_storage_mb: row.get(7)?, - }) - ).unwrap_or(crate::models::UserQuotas { - user_id: id, - max_urls: 100, - max_landings: 10, - max_api_tokens: 5, - max_storage_mb: 100, - current_urls: 0, - current_landings: 0, - current_api_tokens: 0, - current_storage_mb: 0, - }) - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::UserEditTemplate { - admin_username: user.username, - target_user, - quotas, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -#[derive(Deserialize)] -pub struct UserEditForm { - pub account_type: String, - pub metadata: String, - pub max_urls: i64, - pub max_landings: i64, - pub max_api_tokens: i64, - pub max_storage_mb: i64, - pub csrf_token: String, -} - -// POST /admin/users/:id/edit -pub async fn user_edit_post( - State(state): State, - jar: CookieJar, - Path(id): Path, - Form(form): Form, -) -> Response { - let (_user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to(&format!( - "/admin/users/{}/edit?error=Invalid CSRF token", - id - )) - .into_response(); - } - - let conn = state.users_db.lock().unwrap(); - let _ = conn.execute( - "UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;", - rusqlite::params![form.account_type, form.metadata, id], - ); - - let _ = conn.execute( - "INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(user_id) DO UPDATE SET - max_urls = excluded.max_urls, - max_landings = excluded.max_landings, - max_api_tokens = excluded.max_api_tokens, - max_storage_mb = excluded.max_storage_mb;", - rusqlite::params![ - id, - form.max_urls, - form.max_landings, - form.max_api_tokens, - form.max_storage_mb - ], - ); - - Redirect::to(&format!( - "/admin/users/{}?success=User updated successfully", - id - )) - .into_response() -} - -// GET /admin/moderation -pub async fn moderation_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let flagged_items = { - let conn = state.system_db.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at - FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;" - ).unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(GlobalSlugRow { - slug: row.get(0)?, - owner_user_id: row.get(1)?, - target_type: row.get(2)?, - target_id: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, - status: row.get(6)?, - deleted_at: row.get(7)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let logs = { - let conn = state.system_db.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason - FROM moderation_events ORDER BY timestamp DESC LIMIT 50;" - ).unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(ModerationLogEntry { - id: row.get(0)?, - timestamp: row.get(1)?, - admin_username: row.get(2)?, - target_user_id: row.get(3)?, - target_username: row.get(4)?, - resource_type: row.get(5)?, - resource_identifier: row.get(6)?, - action: row.get(7)?, - severity: row.get(8)?, - reason: row.get(9)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::ModerationTemplate { - admin_username: user.username, - flagged_items, - logs, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -#[derive(Deserialize)] -pub struct AdminModerateForm { - pub slug: String, - pub action: String, - pub severity: String, - pub reason: String, - pub csrf_token: String, -} - -// POST /admin/moderation -pub async fn moderation_post( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response(); - } - - let action = form.action.trim().to_lowercase(); - if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) { - return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response(); - } - - let (owner_user_id, target_type) = { - let system_conn = state.system_db.lock().unwrap(); - let row_opt: Option<(i64, String)> = system_conn - .query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;", - [&form.slug], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .unwrap_or(None); - - match row_opt { - Some(r) => r, - None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(), - } - }; - - let owner_username = { - let users_conn = state.users_db.lock().unwrap(); - crate::db::users::get_user_by_id(&users_conn, owner_user_id) - .unwrap_or(None) - .map(|u| u.username) - }; - - { - let system_conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - if action == "deleted" { - let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); - } else { - let _ = system_conn.execute( - "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![action, now, form.slug], - ); - } - - let event_id = Uuid::new_v4().to_string(); - let _ = system_conn.execute( - "INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", - rusqlite::params![ - event_id, - now, - user.username, - owner_user_id, - owner_username, - target_type, - form.slug, - action, - form.severity, - form.reason - ], - ); - - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &user.username, - "CONTENT_MODERATION", - "slug", - &form.slug, - Some(&format!("Action: {}, Reason: {}", action, form.reason)), - ); - } - - Redirect::to(&format!( - "/admin/moderation?success=Moderation action '{}' applied", - action - )) - .into_response() -} - -#[derive(Deserialize)] -pub struct SlugsQuery { - pub search: Option, - pub owner: Option, - pub status: Option, - pub success: Option, - pub error: Option, -} - -// GET /admin/slugs -pub async fn slugs_get( - State(state): State, - jar: CookieJar, - Query(query): Query, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let system_conn = state.system_db.lock().unwrap(); - - let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string(); - let mut values = vec![]; - if let Some(ref s) = query.search { - if !s.trim().is_empty() { - sql.push_str(" AND slug LIKE ?"); - values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim()))); - } - } - if let Some(o) = query.owner { - sql.push_str(" AND owner_user_id = ?"); - values.push(rusqlite::types::Value::Integer(o)); - } - if let Some(ref st) = query.status { - if !st.trim().is_empty() { - sql.push_str(" AND status = ?"); - values.push(rusqlite::types::Value::Text(st.trim().to_string())); - } - } - sql.push_str(" ORDER BY created_at DESC LIMIT 100;"); - - let slugs = { - let mut stmt = system_conn.prepare(&sql).unwrap(); - let rows = stmt - .query_map(rusqlite::params_from_iter(values.iter()), |row| { - Ok(GlobalSlugRow { - slug: row.get(0)?, - owner_user_id: row.get(1)?, - target_type: row.get(2)?, - target_id: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, - status: row.get(6)?, - deleted_at: row.get(7)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let history = { - let mut stmt = system_conn.prepare( - "SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username - FROM slug_history ORDER BY timestamp DESC LIMIT 50;" - ).unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(SlugHistoryRow { - id: row.get(0)?, - slug: row.get(1)?, - old_owner_user_id: row.get(2)?, - new_owner_user_id: row.get(3)?, - action: row.get(4)?, - timestamp: row.get(5)?, - admin_username: row.get(6)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::SlugsTemplate { - admin_username: user.username, - slugs, - history, - csrf_token, - search_filter: query.search, - owner_filter: query.owner, - status_filter: query.status, - success: query.success, - error: query.error, - }; - - template.into_response() -} - -#[derive(Deserialize)] -pub struct AdminTransferForm { - pub slug: String, - pub new_owner_user_id: i64, - pub csrf_token: String, -} - -// POST /admin/slugs/transfer -pub async fn slugs_transfer_post( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); - } - - let user_exists = { - let conn = state.users_db.lock().unwrap(); - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", - [form.new_owner_user_id], - |row| row.get::<_, bool>(0), - ) - .unwrap_or(false) - }; - - if !user_exists { - return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response(); - } - - let (old_owner, target_type, mut new_target_id) = - { - let conn = state.system_db.lock().unwrap(); - let row_opt: Option<(i64, String, String)> = conn.query_row( - "SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;", - [&form.slug], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)) - ).optional().unwrap_or(None); - match row_opt { - Some(r) => r, - None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(), - } - }; - - if old_owner == form.new_owner_user_id { - return Redirect::to("/admin/slugs?error=Slug is already owned by this user") - .into_response(); - } - - let old_dbs = match state.get_user_dbs(old_owner) { - Ok(dbs) => dbs, - Err(_) => { - return Redirect::to("/admin/slugs?error=Failed to load current owner's database") - .into_response() - } - }; - let new_dbs = match state.get_user_dbs(form.new_owner_user_id) { - Ok(dbs) => dbs, - Err(_) => { - return Redirect::to("/admin/slugs?error=Failed to load new owner's database") - .into_response() - } - }; - - { - let old_conn = old_dbs.content.lock().unwrap(); - let new_conn = new_dbs.content.lock().unwrap(); - - if target_type == "url" { - let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) { - Ok(u) => u, - Err(e) => { - return Redirect::to(&format!( - "/admin/slugs?error=Failed to retrieve old URL: {}", - e - )) - .into_response() - } - }; - - if let Some(url) = url_opt { - // Check quota - let users_conn = state.users_db.lock().unwrap(); - let quota_opt = - crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) - .unwrap_or(None); - if let Some(quota) = quota_opt { - if quota.current_urls >= quota.max_urls { - return Redirect::to( - "/admin/slugs?error=New owner has exceeded URL quota limit", - ) - .into_response(); - } - } - - // Copy - let new_url = match crate::db::content::create_url_extended( - &new_conn, - &url.code, - &url.destination, - url.title.as_deref(), - url.description.as_deref(), - &url.tags, - url.expires_at.as_deref(), - url.password_hash.as_deref(), - url.max_access_count, - ) { - Ok(u) => u, - Err(e) => { - return Redirect::to(&format!( - "/admin/slugs?error=Failed to copy URL record: {}", - e - )) - .into_response() - } - }; - new_target_id = new_url.id; - - // Delete old - let _ = crate::db::content::delete_url(&old_conn, &url.id); - } - } else if target_type == "page" { - let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug) - { - Ok(p) => p, - Err(e) => { - return Redirect::to(&format!( - "/admin/slugs?error=Failed to retrieve old page: {}", - e - )) - .into_response() - } - }; - - if let Some(page) = page_opt { - // Check quota - let users_conn = state.users_db.lock().unwrap(); - let quota_opt = - crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) - .unwrap_or(None); - if let Some(quota) = quota_opt { - if quota.current_landings >= quota.max_landings { - return Redirect::to( - "/admin/slugs?error=New owner has exceeded landing page quota limit", - ) - .into_response(); - } - } - - // Copy - let new_page = match crate::db::content::create_landing_page( - &new_conn, - &page.code, - &page.slug, - &page.title, - &page.html_content, - &page.state, - ) { - Ok(p) => p, - Err(e) => { - return Redirect::to(&format!( - "/admin/slugs?error=Failed to copy page record: {}", - e - )) - .into_response() - } - }; - new_target_id = new_page.id; - - // Delete old - let _ = crate::db::content::delete_landing_page(&old_conn, &page.id); - } - } - } - - { - let conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - let _ = conn.execute( - "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", - rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug], - ); - - let _ = conn.execute( - "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) - VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", - rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username], - ); - - let _ = crate::db::audit_events::write_audit_event( - &conn, - &user.username, - "SLUG_TRANSFER", - "slug", - &form.slug, - Some(&format!( - "Transferred from {} to {}", - old_owner, form.new_owner_user_id - )), - ); - } - - Redirect::to(&format!( - "/admin/slugs?success=Slug /{} successfully transferred to user {}", - form.slug, form.new_owner_user_id - )) - .into_response() -} - -#[derive(Deserialize)] -pub struct AdminSlugStatusForm { - pub slug: String, - pub status: String, - pub csrf_token: String, -} - -// POST /admin/slugs/status -pub async fn slugs_status_post( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); - } - - let status = form.status.trim().to_lowercase(); - if !["active", "flagged", "disabled"].contains(&status.as_str()) { - return Redirect::to("/admin/slugs?error=Invalid status").into_response(); - } - - { - let conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - let _ = conn.execute( - "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![status, now, form.slug], - ); - - let _ = crate::db::audit_events::write_audit_event( - &conn, - &user.username, - "SLUG_STATUS_UPDATE", - "slug", - &form.slug, - Some(&format!("Status set to {}", status)), - ); - } - - Redirect::to(&format!( - "/admin/slugs?success=Slug /{} status updated to {}", - form.slug, status - )) - .into_response() -} - -#[derive(Deserialize)] -pub struct AdminSlugDeleteForm { - pub slug: String, - pub csrf_token: String, -} - -// POST /admin/slugs/delete -pub async fn slugs_delete_post( - State(state): State, - jar: CookieJar, - Form(form): Form, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - if !verify_csrf(&session_id, &form.csrf_token) { - return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); - } - - { - let conn = state.system_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - - let old_owner_user_id: Option = conn - .query_row( - "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", - [&form.slug], - |row| row.get(0), - ) - .optional() - .unwrap_or(None); - - let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); - - let _ = conn.execute( - "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) - VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", - rusqlite::params![form.slug, old_owner_user_id, now, user.username], - ); - - let _ = crate::db::audit_events::write_audit_event( - &conn, - &user.username, - "SLUG_RELEASE", - "slug", - &form.slug, - Some("Slug released/deleted from global index"), - ); - } - - Redirect::to(&format!( - "/admin/slugs?success=Slug /{} released successfully", - form.slug - )) - .into_response() -} - -// GET /admin/sessions -pub async fn sessions_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let sessions = { - let conn = state.users_db.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(crate::models::UserSession { - id: row.get(0)?, - user_id: row.get(1)?, - expires_at: row.get(2)?, - created_at: row.get(3)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::SessionsTemplate { - admin_username: user.username, - sessions, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -// POST /admin/sessions/revoke/:id -pub async fn sessions_revoke_post( - State(state): State, - jar: CookieJar, - Path(id): Path, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &form_csrf) { - return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response(); - } - - { - let conn = state.users_db.lock().unwrap(); - let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]); - } - - { - let conn_sys = state.system_db.lock().unwrap(); - let _ = crate::db::audit_events::write_audit_event( - &conn_sys, - &user.username, - "SESSION_REVOKED", - "session", - &id, - None, - ); - } - - Redirect::to("/admin/sessions?success=Session revoked").into_response() -} - -// POST /admin/sessions/revoke-all -pub async fn sessions_revoke_all_post( - State(state): State, - jar: CookieJar, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &form_csrf) { - return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response(); - } - - { - let conn = state.users_db.lock().unwrap(); - let _ = conn.execute("DELETE FROM sessions;", []); - } - - { - let conn_sys = state.system_db.lock().unwrap(); - let _ = crate::db::audit_events::write_audit_event( - &conn_sys, - &user.username, - "SESSIONS_ALL_REVOKED", - "session", - "all", - None, - ); - } - - Redirect::to("/admin/sessions?success=All active sessions revoked").into_response() -} - -// GET /admin/quotas -pub async fn quotas_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let quotas = { - let conn = state.users_db.lock().unwrap(); - let mut stmt = conn.prepare("SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas ORDER BY user_id ASC;").unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(crate::models::UserQuotas { - user_id: row.get(0)?, - max_urls: row.get(1)?, - max_landings: row.get(2)?, - max_api_tokens: row.get(3)?, - max_storage_mb: row.get(4)?, - current_urls: row.get(5)?, - current_landings: row.get(6)?, - current_api_tokens: row.get(7)?, - current_storage_mb: row.get(8)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::QuotasTemplate { - admin_username: user.username, - quotas, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -// POST /admin/quotas -pub async fn quotas_post( - State(state): State, - jar: CookieJar, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &form_csrf) { - return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response(); - } - - let action = form.get("action").cloned().unwrap_or_default(); - let users_conn = state.users_db.lock().unwrap(); - - if action == "reconcile_all" { - let user_ids: Vec = { - let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap(); - let rows = stmt.query_map([], |row| row.get(0)).unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - for uid in user_ids { - if let Ok(user_dbs) = state.get_user_dbs(uid) { - let user_content_conn = user_dbs.content.lock().unwrap(); - let _ = - crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn); - } - } - - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &user.username, - "QUOTAS_RECONCILE_ALL", - "quota", - "all", - None, - ); - - Redirect::to("/admin/quotas?success=All user quotas reconciled successfully") - .into_response() - } else if action == "reconcile" { - let uid_str = form.get("user_id").cloned().unwrap_or_default(); - let uid = uid_str.parse::().unwrap_or(0); - if let Ok(user_dbs) = state.get_user_dbs(uid) { - let user_content_conn = user_dbs.content.lock().unwrap(); - let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn); - - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &user.username, - "QUOTAS_RECONCILE", - "quota", - &uid.to_string(), - None, - ); - Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response() - } else { - Redirect::to("/admin/quotas?error=User databases not found").into_response() - } - } else { - Redirect::to("/admin/quotas?error=Invalid action").into_response() - } -} - -// GET /admin/health -pub async fn health_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let mut db_reports = vec![]; - if let Ok(r) = - crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin") - { - db_reports.push(r); - } - if let Ok(r) = - crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system") - { - db_reports.push(r); - } - if let Ok(r) = - crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users") - { - db_reports.push(r); - } - - let system_db_path = state.config.data_dir.join("admin").join("system.db"); - let users_db_path = state.config.data_dir.join("admin").join("users.db"); - let admin_db_path = state.config.data_dir.join("admin").join("admin.db"); - - let system_db_size = format_size( - std::fs::metadata(&system_db_path) - .map(|m| m.len()) - .unwrap_or(0), - ); - let users_db_size = format_size( - std::fs::metadata(&users_db_path) - .map(|m| m.len()) - .unwrap_or(0), - ); - let admin_db_size = format_size( - std::fs::metadata(&admin_db_path) - .map(|m| m.len()) - .unwrap_or(0), - ); - - let users_dir = state.config.data_dir.join("users"); - let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0)); - let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0)); - - let job_history = { - let conn = state.system_db.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(JobHistoryRow { - id: row.get(0)?, - job_name: row.get(1)?, - status: row.get(2)?, - started_at: row.get(3)?, - finished_at: row.get(4)?, - error_message: row.get(5)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let health_checks = { - let conn = state.system_db.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(HealthCheckRow { - id: row.get(0)?, - object_type: row.get(1)?, - object_id: row.get(2)?, - checked_at: row.get(3)?, - status_code: row.get(4)?, - error_message: row.get(5)?, - is_healthy: row.get(6)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let (registry_errors, registry_warnings) = { - let system_conn = state.system_db.lock().unwrap(); - let users_conn = state.users_db.lock().unwrap(); - match crate::services::registry_validator::RegistryValidator::scan( - &system_conn, - &users_conn, - &state.config.data_dir, - None, - ) { - Ok(issues) => { - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - for issue in issues { - use crate::services::registry_validator::RegistryIssueType; - match issue.issue_type { - RegistryIssueType::StaleReservation - | RegistryIssueType::TenantAdminHasIsolatedContent => { - warnings.push(format!( - "Warning for slug {}: {}", - issue.slug, issue.description - )); - } - _ => { - errors.push(format!( - "Error for slug {}: {}", - issue.slug, issue.description - )); - } - } - } - (errors, warnings) - } - Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]), - } - }; - - let template = crate::templates::HealthTemplate { - admin_username: user.username, - db_reports, - total_data_size, - system_db_size, - users_db_size, - admin_db_size, - tenants_db_size, - job_history, - health_checks, - registry_errors, - registry_warnings, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -// GET /admin/backups -pub async fn backups_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let mut files = vec![]; - if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) { - for entry in dir_entries.flatten() { - let path = entry.path(); - if path.is_file() { - if let Some(filename) = path - .file_name() - .and_then(|n| n.to_str()) - .map(|s| s.to_string()) - { - if filename.ends_with(".tar.gz") { - let meta = entry.metadata().unwrap(); - let size_str = format_size(meta.len()); - let created_str = meta - .created() - .ok() - .map(|c| { - let datetime: chrono::DateTime = c.into(); - datetime.format("%Y-%m-%d %H:%M:%S").to_string() - }) - .unwrap_or_else(|| "-".to_string()); - files.push(BackupFileRow { - filename, - size_str, - created_str, - }); - } - } - } - } - } - files.sort_by(|a, b| b.filename.cmp(&a.filename)); - - let history = { - let conn = state.system_db.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(BackupHistoryRow { - id: row.get(0)?, - backup_path: row.get(1)?, - status: row.get(2)?, - created_at: row.get(3)?, - size_bytes: row.get(4)?, - error_message: row.get(5)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::BackupsTemplate { - admin_username: user.username, - files, - history, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -// POST /admin/backups/create -pub async fn backups_create_post( - State(state): State, - jar: CookieJar, - Form(form): Form>, -) -> Response { - let (_user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &form_csrf) { - return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); - } - - match crate::jobs::backup::perform_backup(&state.db, &state.config).await { - Ok(path) => { - let filename = std::path::Path::new(&path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("backup.tar.gz"); - Redirect::to(&format!( - "/admin/backups?success=Backup created successfully: {}", - filename - )) - .into_response() - } - Err(e) => Redirect::to(&format!( - "/admin/backups?error=Failed to generate backup: {}", - e - )) - .into_response(), - } -} - -// GET /admin/backups/download/:filename -pub async fn backups_download_get( - State(state): State, - jar: CookieJar, - Path(filename): Path, -) -> Response { - if require_auth(&state, &jar).await.is_err() { - return StatusCode::UNAUTHORIZED.into_response(); - } - - if filename.contains('/') || filename.contains('\\') || filename.contains("..") { - return StatusCode::BAD_REQUEST.into_response(); - } - - let backup_path = state.config.backup_dir.join(&filename); - if !backup_path.exists() { - return StatusCode::NOT_FOUND.into_response(); - } - - match std::fs::read(&backup_path) { - Ok(bytes) => { - let body = axum::body::Body::from(bytes); - Response::builder() - .header("content-type", "application/octet-stream") - .header( - "content-disposition", - format!("attachment; filename=\"{}\"", filename), - ) - .body(body) - .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) - } - Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } -} - -// POST /admin/backups/delete/:filename -pub async fn backups_delete_post( - State(state): State, - jar: CookieJar, - Path(filename): Path, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &form_csrf) { - return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); - } - - if filename.contains('/') || filename.contains('\\') || filename.contains("..") { - return Redirect::to("/admin/backups?error=Invalid filename").into_response(); - } - - let backup_path = state.config.backup_dir.join(&filename); - if !backup_path.exists() { - return Redirect::to("/admin/backups?error=Backup file not found").into_response(); - } - - match std::fs::remove_file(&backup_path) { - Ok(_) => { - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &user.username, - "BACKUP_DELETE", - "backup", - &filename, - None, - ); - Redirect::to("/admin/backups?success=Backup archive deleted").into_response() - } - Err(e) => Redirect::to(&format!( - "/admin/backups?error=Failed to delete backup file: {}", - e - )) - .into_response(), - } -} - -// POST /admin/backups/restore -pub async fn backups_restore_post( - State(state): State, - jar: CookieJar, - mut multipart: axum::extract::Multipart, -) -> Response { - let (user, session_id) = match require_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let mut backup_bytes = vec![]; - let mut confirm_text = String::new(); - let mut csrf_token = String::new(); - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or_default().to_string(); - if name == "backup_file" { - if let Ok(bytes) = field.bytes().await { - backup_bytes = bytes.to_vec(); - } - } else if name == "confirm_text" { - if let Ok(text) = field.text().await { - confirm_text = text.trim().to_string(); - } - } else if name == "csrf_token" { - if let Ok(text) = field.text().await { - csrf_token = text.trim().to_string(); - } - } - } - - if !verify_csrf(&session_id, &csrf_token) { - return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); - } - - if confirm_text != "RESTORE" { - return Redirect::to( - "/admin/backups?error=Confirmation text mismatch. Please type RESTORE.", - ) - .into_response(); - } - - if backup_bytes.is_empty() { - return Redirect::to("/admin/backups?error=Backup file is empty or missing.") - .into_response(); - } - - let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz"); - if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) { - return Redirect::to(&format!( - "/admin/backups?error=Failed to save uploaded file: {}", - e - )) - .into_response(); - } - - match crate::cli::restore::run( - temp_file_path.to_string_lossy().to_string(), - None, - state.config.clone(), - ) - .await - { - Ok(_) => { - let _ = std::fs::remove_file(&temp_file_path); - let conn = state.users_db.lock().unwrap(); - let _ = conn.execute("DELETE FROM sessions;", []); - - let system_conn = state.system_db.lock().unwrap(); - let _ = crate::db::audit_events::write_audit_event( - &system_conn, - &user.username, - "RESTORE_EXECUTION", - "backup", - "upload", - None, - ); - Redirect::to("/admin/login?success=Restore successful. Please log in again.") - .into_response() - } - Err(e) => { - let _ = std::fs::remove_file(&temp_file_path); - Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response() - } - } -} - -// GET /api-tokens -pub async fn api_tokens_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let tokens = { - let conn = state.users_db.lock().unwrap(); - let mut stmt = conn - .prepare( - "SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;", - ) - .unwrap(); - let rows = stmt - .query_map([user.id], |row| { - Ok(crate::models::UserApiToken { - id: row.get(0)?, - user_id: row.get(1)?, - token_hash: row.get(2)?, - created_at: row.get(3)?, - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::ApiTokensTemplate { - admin_username: user.username.clone(), - username: user.username, - tokens, - new_token: params.get("new_token").cloned(), - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -// POST /api-tokens/create -pub async fn api_tokens_create_post( - State(state): State, - jar: CookieJar, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &form_csrf) { - return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response(); - } - - use sha2::Digest; - let raw_token = format!("key_{}", generate_token(32)); - let mut hasher = sha2::Sha256::new(); - hasher.update(raw_token.as_bytes()); - let hashed_token = hex::encode(hasher.finalize()); - - { - let conn = state.users_db.lock().unwrap(); - let now = Utc::now().to_rfc3339(); - let _ = conn.execute( - "INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);", - rusqlite::params![user.id, hashed_token, now], - ); - } - - { - let conn_sys = state.system_db.lock().unwrap(); - let _ = crate::db::audit_events::write_audit_event( - &conn_sys, - &user.username, - "API_TOKEN_CREATED", - "api_token", - "new", - None, - ); - } - - Redirect::to(&format!( - "/api-tokens?new_token={}&success=Token generated successfully", - raw_token - )) - .into_response() -} - -// POST /api-tokens/revoke/:id -pub async fn api_tokens_revoke_post( - State(state): State, - jar: CookieJar, - Path(token_id): Path, - Form(form): Form>, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); - if !verify_csrf(&session_id, &form_csrf) { - return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response(); - } - - { - let conn = state.users_db.lock().unwrap(); - let _ = conn.execute( - "DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;", - [token_id, user.id], - ); - } - - { - let conn_sys = state.system_db.lock().unwrap(); - let _ = crate::db::audit_events::write_audit_event( - &conn_sys, - &user.username, - "API_TOKEN_REVOKED", - "api_token", - &token_id.to_string(), - None, - ); - } - - Redirect::to("/api-tokens?success=API token revoked").into_response() -} - -// GET /analytics -pub async fn user_analytics_get( - State(state): State, - jar: CookieJar, - Query(params): Query>, -) -> Response { - let (user, session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return Redirect::to("/user/dashboard?error=Database error").into_response(), - }; - - let conn = user_dbs.analytics.lock().unwrap(); - - let total_clicks = conn - .query_row("SELECT COUNT(*) FROM visits;", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap_or(0); - let unique_visitors = conn - .query_row( - "SELECT COUNT(DISTINCT ip_address) FROM visits;", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap_or(0); - let direct_clicks = conn - .query_row( - "SELECT COUNT(*) FROM visits WHERE referer = '' OR referer = 'direct';", - [], - |row| row.get::<_, i64>(0), - ) - .unwrap_or(0); - let referred_clicks = total_clicks - direct_clicks; - - let referrers_chart = "
No referrer channels logged yet
".to_string(); - let browsers_chart = "
No browser traffic logged yet
".to_string(); - - let visits = { - let mut stmt = conn.prepare("SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits ORDER BY timestamp DESC LIMIT 50;").unwrap(); - let rows = stmt - .query_map([], |row| { - Ok(crate::models::VisitRecord { - id: row.get(0)?, - target_type: row.get(1)?, - target_id: row.get(2)?, - timestamp: row.get(3)?, - ip_address: row.get(4)?, - user_agent: row.get(5)?, - referer: row.get(6)?, - accept_language: row.get(7)?, - country: row.get(8)?, - status_code: row.get(9)?, - owner_user_id: Some(user.id), - }) - }) - .unwrap(); - rows.filter_map(|r| r.ok()).collect() - }; - - let csrf_token = generate_csrf_token(&session_id); - - let template = crate::templates::UserAnalyticsTemplate { - admin_username: user.username.clone(), - username: user.username, - total_clicks, - unique_visitors, - direct_clicks, - referred_clicks, - referrers_chart, - browsers_chart, - visits, - csrf_token, - success: params.get("success").cloned(), - error: params.get("error").cloned(), - }; - - template.into_response() -} - -pub async fn user_url_analytics_get( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - let (user, _session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); - match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", - [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) { - Ok(val) => val, - Err(rusqlite::Error::QueryReturnedNoRows) => { - return Redirect::to("/user/urls?error=Link not found").into_response(); - } - Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), - } - }; - - if owner_user_id != user.id || target_type != "url" { - return StatusCode::FORBIDDEN.into_response(); - } - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), - }; - - let url = { - let conn = user_dbs.content.lock().unwrap(); - match crate::db::content::get_url_by_id(&conn, &id) { - Ok(Some(u)) => u, - Ok(None) => return Redirect::to("/user/urls?error=Link not found").into_response(), - Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), - } - }; - - let conn = user_dbs.analytics.lock().unwrap(); - - let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); - let has_utm_source = schema_cols.contains("utm_source"); - let has_utm_campaign = schema_cols.contains("utm_campaign"); - if has_utm_source || has_utm_campaign { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "UTM mapping verification failed", - ) - .into_response(); - } - - let (clean_date_from, clean_date_to) = - match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; - - let total_clicks = get_target_visit_total_filtered( - &conn, - "url", - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or(0); - - let unique_visitors = get_target_unique_visitors(&conn, "url", &id).unwrap_or(0); - let qr_scans = crate::db::qr::get_qr_scan_count(&conn, &id).unwrap_or(0); - let direct_clicks = (total_clicks - qr_scans).max(0); - - let clicks_data = get_clicks_trend(&conn, "url", &id, 30) - .or_else(|_| get_clicks_trend_raw(&conn, "url", &id, 30)) - .unwrap_or_default(); - let mut trend_map = std::collections::BTreeMap::new(); - for i in (0..30).rev() { - let date_str = (Utc::now() - chrono::Duration::days(i)) - .format("%Y-%m-%d") - .to_string(); - trend_map.insert(date_str, 0i64); - } - for (d, c) in clicks_data { - trend_map.insert(d, c); - } - let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); - let traffic_chart = generate_line_chart(&formatted_trend); - - let monthly_data = get_monthly_clicks_trend(&conn, "url", &id, 12).unwrap_or_default(); - let monthly_chart = generate_line_chart(&monthly_data); - - let countries_data = get_metric_rankings(&conn, "url", &id, "country", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "country", 5)) - .unwrap_or_default(); - let countries_chart = generate_bar_chart(&countries_data); - - let referrers_data = get_metric_rankings(&conn, "url", &id, "referrer", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "referrer", 5)) - .unwrap_or_default(); - let referrers_chart = generate_bar_chart(&referrers_data); - - let browsers_data = get_metric_rankings(&conn, "url", &id, "browser", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "browser", 5)) - .unwrap_or_default(); - let browsers_chart = generate_bar_chart(&browsers_data); - - let calculated_total_pages = (total_clicks as usize).div_ceil(ANALYTICS_PAGE_SIZE); - let total_pages = std::cmp::max(1, calculated_total_pages); - let requested_page = query.analytics_page.unwrap_or(1); - let current_page = if requested_page == 0 { - 1 - } else { - requested_page - } - .clamp(1, total_pages); - let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; - - let visits_raw = get_target_visits_paginated( - &conn, - "url", - &id, - ANALYTICS_PAGE_SIZE as i64, - offset as i64, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or_default(); - - let visits: Vec = visits_raw - .into_iter() - .enumerate() - .map(|(idx, r)| { - let (browser, _, _) = parse_ua(&r.user_agent); - let referrer = clean_referrer(&r.referer); - let sr = offset + idx + 1; - crate::templates::VisitorLogEntry { - sr, - timestamp: r.timestamp, - ip_address: r.ip_address, - country: if r.country.is_empty() { - "Unknown".to_string() - } else { - r.country - }, - referrer, - browser, - user_agent: r.user_agent, - utm_source: "-".to_string(), - utm_campaign: "-".to_string(), - } - }) - .collect(); - - let start_page = current_page.saturating_sub(3).max(1); - let end_page = std::cmp::min(total_pages, current_page + 3); - let visible_pages: Vec = (start_page..=end_page).collect(); - - let page_start = if total_clicks == 0 { 0 } else { offset + 1 }; - let page_end = if total_clicks == 0 { - 0 - } else { - std::cmp::min(total_clicks as usize, offset + ANALYTICS_PAGE_SIZE) - }; - - let template = crate::templates::UrlAnalyticsTemplate { - admin_username: user.username, - url, - total_clicks, - unique_visitors, - qr_scans, - direct_clicks, - traffic_chart, - monthly_chart, - countries_chart, - referrers_chart, - browsers_chart, - visits, - current_page, - total_pages, - visible_pages, - total_records: total_clicks, - page_start, - page_end, - date_from: clean_date_from, - date_to: clean_date_to, - is_admin: false, - }; - - template.into_response() -} - -pub async fn user_page_analytics_get( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - let (user, _session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); - match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", - [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) { - Ok(val) => val, - Err(rusqlite::Error::QueryReturnedNoRows) => { - return Redirect::to("/user/pages?error=Landing page not found").into_response(); - } - Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), - } - }; - - if owner_user_id != user.id || target_type != "page" { - return StatusCode::FORBIDDEN.into_response(); - } - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), - }; - - let page = { - let conn = user_dbs.content.lock().unwrap(); - match crate::db::content::get_landing_page_by_id(&conn, &id) { - Ok(Some(p)) => p, - Ok(None) => { - return Redirect::to("/user/pages?error=Landing page not found").into_response() - } - Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), - } - }; - - let conn = user_dbs.analytics.lock().unwrap(); - - let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); - let has_utm_source = schema_cols.contains("utm_source"); - let has_utm_campaign = schema_cols.contains("utm_campaign"); - if has_utm_source || has_utm_campaign { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "UTM mapping verification failed", - ) - .into_response(); - } - - let (clean_date_from, clean_date_to) = - match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; - - let total_views = get_target_visit_total_filtered( - &conn, - "page", - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or(0); - - let unique_visitors = get_target_unique_visitors(&conn, "page", &id).unwrap_or(0); - - let clicks_data = get_clicks_trend(&conn, "page", &id, 30) - .or_else(|_| get_clicks_trend_raw(&conn, "page", &id, 30)) - .unwrap_or_default(); - let mut trend_map = std::collections::BTreeMap::new(); - for i in (0..30).rev() { - let date_str = (Utc::now() - chrono::Duration::days(i)) - .format("%Y-%m-%d") - .to_string(); - trend_map.insert(date_str, 0i64); - } - for (d, c) in clicks_data { - trend_map.insert(d, c); - } - let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); - let traffic_chart = generate_line_chart(&formatted_trend); - - let monthly_data = get_monthly_clicks_trend(&conn, "page", &id, 12).unwrap_or_default(); - let monthly_chart = generate_line_chart(&monthly_data); - - let countries_data = get_metric_rankings(&conn, "page", &id, "country", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "country", 5)) - .unwrap_or_default(); - let countries_chart = generate_bar_chart(&countries_data); - - let referrers_data = get_metric_rankings(&conn, "page", &id, "referrer", 5) - .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "referrer", 5)) - .unwrap_or_default(); - let referrers_chart = generate_bar_chart(&referrers_data); - - let calculated_total_pages = (total_views as usize).div_ceil(ANALYTICS_PAGE_SIZE); - let total_pages = std::cmp::max(1, calculated_total_pages); - let requested_page = query.analytics_page.unwrap_or(1); - let current_page = if requested_page == 0 { - 1 - } else { - requested_page - } - .clamp(1, total_pages); - let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; - - let visits_raw = get_target_visits_paginated( - &conn, - "page", - &id, - ANALYTICS_PAGE_SIZE as i64, - offset as i64, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or_default(); - - let visits: Vec = visits_raw - .into_iter() - .enumerate() - .map(|(idx, r)| { - let (browser, _, _) = parse_ua(&r.user_agent); - let referrer = clean_referrer(&r.referer); - let sr = offset + idx + 1; - crate::templates::VisitorLogEntry { - sr, - timestamp: r.timestamp, - ip_address: r.ip_address, - country: if r.country.is_empty() { - "Unknown".to_string() - } else { - r.country - }, - referrer, - browser, - user_agent: r.user_agent, - utm_source: "-".to_string(), - utm_campaign: "-".to_string(), - } - }) - .collect(); - - let start_page = current_page.saturating_sub(3).max(1); - let end_page = std::cmp::min(total_pages, current_page + 3); - let visible_pages: Vec = (start_page..=end_page).collect(); - - let page_start = if total_views == 0 { 0 } else { offset + 1 }; - let page_end = if total_views == 0 { - 0 - } else { - std::cmp::min(total_views as usize, offset + ANALYTICS_PAGE_SIZE) - }; - - let template = crate::templates::PageAnalyticsTemplate { - admin_username: user.username, - page, - total_views, - unique_visitors, - traffic_chart, - monthly_chart, - countries_chart, - referrers_chart, - visits, - current_page, - total_pages, - visible_pages, - total_records: total_views, - page_start, - page_end, - date_from: clean_date_from, - date_to: clean_date_to, - is_admin: false, - }; - - template.into_response() -} - -async fn perform_user_csv_export( - user_dbs: crate::state::UserDbs, - target_type: &'static str, - id: String, - date_from: Option, - date_to: Option, -) -> Response { - let (clean_date_from, clean_date_to) = - match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; - - let target_exists = { - let conn = user_dbs.content.lock().unwrap(); - if target_type == "url" { - get_url_by_id(&conn, &id) - .map(|u| u.is_some()) - .unwrap_or(false) - } else { - get_landing_page_by_id(&conn, &id) - .map(|p| p.is_some()) - .unwrap_or(false) - } - }; - if !target_exists { - return (StatusCode::NOT_FOUND, "Target not found").into_response(); - } - - let count = { - let conn = user_dbs.analytics.lock().unwrap(); - get_target_visit_total_filtered( - &conn, - target_type, - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or(0) - }; - - let (has_utm_source, has_utm_campaign) = { - let conn = user_dbs.analytics.lock().unwrap(); - let cols = get_visits_schema_columns(&conn).unwrap_or_default(); - (cols.contains("utm_source"), cols.contains("utm_campaign")) - }; - - let (tx, rx) = - tokio::sync::mpsc::channel::>(32); - let analytics_db = user_dbs.analytics.clone(); - let target_id = id.clone(); - - tokio::task::spawn_blocking(move || { - let conn = analytics_db.lock().unwrap(); - - let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string(); - if has_utm_source { - header.push_str(",UTM Source"); - } - if has_utm_campaign { - header.push_str(",UTM Campaign"); - } - header.push('\n'); - - if tx - .blocking_send(Ok(axum::body::Bytes::from(header))) - .is_err() - { - return; - } - - let select_fields = if has_utm_source && has_utm_campaign { - "timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign" - } else if has_utm_source { - "timestamp, ip_address, country, referer, user_agent, utm_source" - } else if has_utm_campaign { - "timestamp, ip_address, country, referer, user_agent, utm_campaign" - } else { - "timestamp, ip_address, country, referer, user_agent" - }; - - let mut sql = format!( - "SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2", - select_fields - ); - let mut params: Vec> = - vec![Box::new(target_type.to_string()), Box::new(target_id)]; - - if let Some(df) = clean_date_from.as_deref() { - sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); - params.push(Box::new(format!("{}T00:00:00Z", df))); - } - - if let Some(dt) = clean_date_to.as_deref() { - if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { - let next_day = parsed_date + chrono::Duration::days(1); - sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); - params.push(Box::new(format!( - "{}T00:00:00Z", - next_day.format("%Y-%m-%d") - ))); - } - } - - sql.push_str(" ORDER BY timestamp DESC, id DESC"); - - let mut stmt = match conn.prepare(&sql) { - Ok(s) => s, - Err(_) => return, - }; - - let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) { - Ok(r) => r, - Err(_) => return, - }; - - let mut csv_buffer = String::new(); - - while let Ok(Some(row)) = rows.next() { - let timestamp: String = row.get(0).unwrap_or_default(); - let ip_address: String = row.get(1).unwrap_or_default(); - let country: String = row.get(2).unwrap_or_default(); - let referer: String = row.get(3).unwrap_or_default(); - let user_agent: String = row.get(4).unwrap_or_default(); - - let (browser, _, _) = parse_ua(&user_agent); - let referrer = clean_referrer(&referer); - let country_display = if country.is_empty() { - "Unknown".to_string() - } else { - country - }; - - let mut line = format!( - "{},{},{},{},{},{}", - escape_csv_field(×tamp), - escape_csv_field(&ip_address), - escape_csv_field(&country_display), - escape_csv_field(&referrer), - escape_csv_field(&browser), - escape_csv_field(&user_agent) - ); - - let mut col_idx = 5; - if has_utm_source { - let utm_src: String = row.get(col_idx).unwrap_or_default(); - line.push_str(&format!(",{}", escape_csv_field(&utm_src))); - col_idx += 1; - } - if has_utm_campaign { - let utm_camp: String = row.get(col_idx).unwrap_or_default(); - line.push_str(&format!(",{}", escape_csv_field(&utm_camp))); - } - line.push('\n'); - - csv_buffer.push_str(&line); - if csv_buffer.len() >= 8192 { - let bytes = axum::body::Bytes::from(csv_buffer); - if tx.blocking_send(Ok(bytes)).is_err() { - return; - } - csv_buffer = String::new(); - } - } - - if !csv_buffer.is_empty() { - let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer))); - } - }); - - let stream = DbExportStream { receiver: rx }; - let filename = if target_type == "url" { - format!("url_{}_analytics.csv", id) - } else { - format!("page_{}_analytics.csv", id) - }; - - ( - StatusCode::OK, - [ - ("Content-Type", "text/csv"), - ( - "Content-Disposition", - &format!("attachment; filename=\"{}\"", filename), - ), - ("X-BZOD-Export-Records", &count.to_string()), - ], - axum::body::Body::from_stream(stream), - ) - .into_response() -} - -async fn perform_user_json_export( - user_dbs: crate::state::UserDbs, - target_type: &'static str, - id: String, - date_from: Option, - date_to: Option, -) -> Response { - let (clean_date_from, clean_date_to) = - match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { - Ok(res) => res, - Err(status) => return status.into_response(), - }; - - let target_exists = { - let conn = user_dbs.content.lock().unwrap(); - if target_type == "url" { - get_url_by_id(&conn, &id) - .map(|u| u.is_some()) - .unwrap_or(false) - } else { - get_landing_page_by_id(&conn, &id) - .map(|p| p.is_some()) - .unwrap_or(false) - } - }; - if !target_exists { - return (StatusCode::NOT_FOUND, "Target not found").into_response(); - } - - let count = { - let conn = user_dbs.analytics.lock().unwrap(); - get_target_visit_total_filtered( - &conn, - target_type, - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) - .unwrap_or(0) - }; - - if count > MAX_JSON_EXPORT_ROWS as i64 { - return StatusCode::PAYLOAD_TOO_LARGE.into_response(); - } - - let visits_raw = { - let conn = user_dbs.analytics.lock().unwrap(); - match get_target_visits_all_in_memory( - &conn, - target_type, - &id, - clean_date_from.as_deref(), - clean_date_to.as_deref(), - ) { - Ok(v) => v, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - } - }; - - #[derive(serde::Serialize)] - struct JsonExportRow { - timestamp: String, - ip_address: String, - country: String, - referrer: String, - browser: String, - user_agent: String, - } - - let export_rows: Vec = visits_raw - .into_iter() - .map(|r| { - let (browser, _, _) = parse_ua(&r.user_agent); - let referrer = clean_referrer(&r.referer); - let country_display = if r.country.is_empty() { - "Unknown".to_string() - } else { - r.country - }; - JsonExportRow { - timestamp: r.timestamp, - ip_address: r.ip_address, - country: country_display, - referrer, - browser, - user_agent: r.user_agent, - } - }) - .collect(); - - let body_str = match serde_json::to_string(&export_rows) { - Ok(s) => s, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response() - } - }; - - let filename = if target_type == "url" { - format!("url_{}_analytics.json", id) - } else { - format!("page_{}_analytics.json", id) - }; - - ( - StatusCode::OK, - [ - ("Content-Type", "application/json"), - ( - "Content-Disposition", - &format!("attachment; filename=\"{}\"", filename), - ), - ("X-BZOD-Export-Records", &count.to_string()), - ], - body_str, - ) - .into_response() -} - -pub async fn user_url_analytics_csv_export( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - let (user, _session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); - match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", - [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) { - Ok(val) => val, - Err(rusqlite::Error::QueryReturnedNoRows) => { - return StatusCode::NOT_FOUND.into_response(); - } - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } - }; - - if owner_user_id != user.id || target_type != "url" { - return StatusCode::FORBIDDEN.into_response(); - } - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - }; - - perform_user_csv_export(user_dbs, "url", id, query.date_from, query.date_to).await -} - -pub async fn user_url_analytics_json_export( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - let (user, _session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); - match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", - [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) { - Ok(val) => val, - Err(rusqlite::Error::QueryReturnedNoRows) => { - return StatusCode::NOT_FOUND.into_response(); - } - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } - }; - - if owner_user_id != user.id || target_type != "url" { - return StatusCode::FORBIDDEN.into_response(); - } - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - }; - - perform_user_json_export(user_dbs, "url", id, query.date_from, query.date_to).await -} - -pub async fn user_page_analytics_csv_export( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - let (user, _session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); - match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", - [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) { - Ok(val) => val, - Err(rusqlite::Error::QueryReturnedNoRows) => { - return StatusCode::NOT_FOUND.into_response(); - } - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } - }; - - if owner_user_id != user.id || target_type != "page" { - return StatusCode::FORBIDDEN.into_response(); - } - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - }; - - perform_user_csv_export(user_dbs, "page", id, query.date_from, query.date_to).await -} - -pub async fn user_page_analytics_json_export( - State(state): State, - jar: CookieJar, - Path(id): Path, - Query(query): Query, -) -> Response { - let (user, _session_id) = match require_user_auth(&state, &jar).await { - Ok(u) => u, - Err(redir) => return redir.into_response(), - }; - - // Ownership check - let (owner_user_id, target_type) = { - let conn = state.system_db.lock().unwrap(); - match conn.query_row( - "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", - [&id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) { - Ok(val) => val, - Err(rusqlite::Error::QueryReturnedNoRows) => { - return StatusCode::NOT_FOUND.into_response(); - } - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } - }; - - if owner_user_id != user.id || target_type != "page" { - return StatusCode::FORBIDDEN.into_response(); - } - - let user_dbs = match state.get_user_dbs(user.id) { - Ok(dbs) => dbs, - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - }; - - perform_user_json_export(user_dbs, "page", id, query.date_from, query.date_to).await -} diff --git a/src/web/admin/analytics.rs b/src/web/admin/analytics.rs new file mode 100644 index 0000000..3ba1389 --- /dev/null +++ b/src/web/admin/analytics.rs @@ -0,0 +1,1007 @@ +use super::*; + +// GET /admin/analytics/url/:id +pub async fn url_analytics_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let url = { + let conn = state.content_db.lock().unwrap(); + match get_url_by_id(&conn, &id) { + Ok(Some(u)) => u, + Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + + let conn = state.analytics_db.lock().unwrap(); + + let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); + let has_utm_source = schema_cols.contains("utm_source"); + let has_utm_campaign = schema_cols.contains("utm_campaign"); + if has_utm_source || has_utm_campaign { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "UTM mapping verification failed", + ) + .into_response(); + } + + let (clean_date_from, clean_date_to) = + match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; + + let total_clicks = get_target_visit_total_filtered( + &conn, + "url", + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or(0); + + let unique_visitors = get_target_unique_visitors(&conn, "url", &id).unwrap_or(0); + let qr_scans = crate::db::qr::get_qr_scan_count(&conn, &id).unwrap_or(0); + let direct_clicks = (total_clicks - qr_scans).max(0); + + let clicks_data = get_clicks_trend(&conn, "url", &id, 30) + .or_else(|_| get_clicks_trend_raw(&conn, "url", &id, 30)) + .unwrap_or_default(); + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let monthly_data = get_monthly_clicks_trend(&conn, "url", &id, 12).unwrap_or_default(); + let monthly_chart = generate_line_chart(&monthly_data); + + let countries_data = get_metric_rankings(&conn, "url", &id, "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "country", 5)) + .unwrap_or_default(); + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = get_metric_rankings(&conn, "url", &id, "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "referrer", 5)) + .unwrap_or_default(); + let referrers_chart = generate_bar_chart(&referrers_data); + + let browsers_data = get_metric_rankings(&conn, "url", &id, "browser", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "browser", 5)) + .unwrap_or_default(); + let browsers_chart = generate_bar_chart(&browsers_data); + + let calculated_total_pages = (total_clicks as usize).div_ceil(ANALYTICS_PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.analytics_page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; + + let visits_raw = get_target_visits_paginated( + &conn, + "url", + &id, + ANALYTICS_PAGE_SIZE as i64, + offset as i64, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or_default(); + + let visits: Vec = visits_raw + .into_iter() + .enumerate() + .map(|(idx, r)| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + let sr = offset + idx + 1; + crate::templates::VisitorLogEntry { + sr, + timestamp: r.timestamp, + ip_address: r.ip_address, + country: if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }, + referrer, + browser, + user_agent: r.user_agent, + utm_source: "-".to_string(), + utm_campaign: "-".to_string(), + } + }) + .collect(); + + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + + let page_start = if total_clicks == 0 { 0 } else { offset + 1 }; + let page_end = if total_clicks == 0 { + 0 + } else { + std::cmp::min(total_clicks as usize, offset + ANALYTICS_PAGE_SIZE) + }; + + let template = crate::templates::UrlAnalyticsTemplate { + admin_username: user.username, + url, + total_clicks, + unique_visitors, + qr_scans, + direct_clicks, + traffic_chart, + monthly_chart, + countries_chart, + referrers_chart, + browsers_chart, + visits, + current_page, + total_pages, + visible_pages, + total_records: total_clicks, + page_start, + page_end, + date_from: clean_date_from, + date_to: clean_date_to, + is_admin: true, + }; + + template.into_response() +} + +// GET /admin/analytics/page/:id +pub async fn page_analytics_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let page = { + let conn = state.content_db.lock().unwrap(); + match get_landing_page_by_id(&conn, &id) { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + + let conn = state.analytics_db.lock().unwrap(); + + let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); + let has_utm_source = schema_cols.contains("utm_source"); + let has_utm_campaign = schema_cols.contains("utm_campaign"); + if has_utm_source || has_utm_campaign { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "UTM mapping verification failed", + ) + .into_response(); + } + + let (clean_date_from, clean_date_to) = + match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; + + let total_views = get_target_visit_total_filtered( + &conn, + "page", + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or(0); + + let unique_visitors = get_target_unique_visitors(&conn, "page", &id).unwrap_or(0); + + let clicks_data = get_clicks_trend(&conn, "page", &id, 30) + .or_else(|_| get_clicks_trend_raw(&conn, "page", &id, 30)) + .unwrap_or_default(); + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let monthly_data = get_monthly_clicks_trend(&conn, "page", &id, 12).unwrap_or_default(); + let monthly_chart = generate_line_chart(&monthly_data); + + let countries_data = get_metric_rankings(&conn, "page", &id, "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "country", 5)) + .unwrap_or_default(); + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = get_metric_rankings(&conn, "page", &id, "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "referrer", 5)) + .unwrap_or_default(); + let referrers_chart = generate_bar_chart(&referrers_data); + + let calculated_total_pages = (total_views as usize).div_ceil(ANALYTICS_PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.analytics_page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; + + let visits_raw = get_target_visits_paginated( + &conn, + "page", + &id, + ANALYTICS_PAGE_SIZE as i64, + offset as i64, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or_default(); + + let visits: Vec = visits_raw + .into_iter() + .enumerate() + .map(|(idx, r)| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + let sr = offset + idx + 1; + crate::templates::VisitorLogEntry { + sr, + timestamp: r.timestamp, + ip_address: r.ip_address, + country: if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }, + referrer, + browser, + user_agent: r.user_agent, + utm_source: "-".to_string(), + utm_campaign: "-".to_string(), + } + }) + .collect(); + + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + + let page_start = if total_views == 0 { 0 } else { offset + 1 }; + let page_end = if total_views == 0 { + 0 + } else { + std::cmp::min(total_views as usize, offset + ANALYTICS_PAGE_SIZE) + }; + + let template = crate::templates::PageAnalyticsTemplate { + admin_username: user.username, + page, + total_views, + unique_visitors, + traffic_chart, + monthly_chart, + countries_chart, + referrers_chart, + visits, + current_page, + total_pages, + visible_pages, + total_records: total_views, + page_start, + page_end, + date_from: clean_date_from, + date_to: clean_date_to, + is_admin: true, + }; + + template.into_response() +} + +pub async fn url_analytics_csv_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + perform_csv_export(state, "url", id, query.date_from, query.date_to).await +} + +pub async fn url_analytics_json_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + perform_json_export(state, "url", id, query.date_from, query.date_to).await +} + +pub async fn page_analytics_csv_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + perform_csv_export(state, "page", id, query.date_from, query.date_to).await +} + +pub async fn page_analytics_json_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + perform_json_export(state, "page", id, query.date_from, query.date_to).await +} + +// GET /analytics +pub async fn user_analytics_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return Redirect::to("/user/dashboard?error=Database error").into_response(), + }; + + let conn = user_dbs.analytics.lock().unwrap(); + + let total_clicks = conn + .query_row("SELECT COUNT(*) FROM visits;", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap_or(0); + let unique_visitors = conn + .query_row( + "SELECT COUNT(DISTINCT ip_address) FROM visits;", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0); + let direct_clicks = conn + .query_row( + "SELECT COUNT(*) FROM visits WHERE referer = '' OR referer = 'direct';", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0); + let referred_clicks = total_clicks - direct_clicks; + + let referrers_chart = "
No referrer channels logged yet
".to_string(); + let browsers_chart = "
No browser traffic logged yet
".to_string(); + + let visits = { + let mut stmt = conn.prepare("SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits ORDER BY timestamp DESC LIMIT 50;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(crate::models::VisitRecord { + id: row.get(0)?, + target_type: row.get(1)?, + target_id: row.get(2)?, + timestamp: row.get(3)?, + ip_address: row.get(4)?, + user_agent: row.get(5)?, + referer: row.get(6)?, + accept_language: row.get(7)?, + country: row.get(8)?, + status_code: row.get(9)?, + owner_user_id: Some(user.id), + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UserAnalyticsTemplate { + admin_username: user.username.clone(), + username: user.username, + total_clicks, + unique_visitors, + direct_clicks, + referred_clicks, + referrers_chart, + browsers_chart, + visits, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +pub async fn user_url_analytics_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + // Ownership check + let (owner_user_id, target_type) = { + let conn = state.system_db.lock().unwrap(); + match conn.query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + [&id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) { + Ok(val) => val, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return Redirect::to("/user/urls?error=Link not found").into_response(); + } + Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), + } + }; + + if owner_user_id != user.id || target_type != "url" { + return StatusCode::FORBIDDEN.into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), + }; + + let url = { + let conn = user_dbs.content.lock().unwrap(); + match crate::db::content::get_url_by_id(&conn, &id) { + Ok(Some(u)) => u, + Ok(None) => return Redirect::to("/user/urls?error=Link not found").into_response(), + Err(_) => return Redirect::to("/user/urls?error=Database error").into_response(), + } + }; + + let conn = user_dbs.analytics.lock().unwrap(); + + let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); + let has_utm_source = schema_cols.contains("utm_source"); + let has_utm_campaign = schema_cols.contains("utm_campaign"); + if has_utm_source || has_utm_campaign { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "UTM mapping verification failed", + ) + .into_response(); + } + + let (clean_date_from, clean_date_to) = + match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; + + let total_clicks = get_target_visit_total_filtered( + &conn, + "url", + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or(0); + + let unique_visitors = get_target_unique_visitors(&conn, "url", &id).unwrap_or(0); + let qr_scans = crate::db::qr::get_qr_scan_count(&conn, &id).unwrap_or(0); + let direct_clicks = (total_clicks - qr_scans).max(0); + + let clicks_data = get_clicks_trend(&conn, "url", &id, 30) + .or_else(|_| get_clicks_trend_raw(&conn, "url", &id, 30)) + .unwrap_or_default(); + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let monthly_data = get_monthly_clicks_trend(&conn, "url", &id, 12).unwrap_or_default(); + let monthly_chart = generate_line_chart(&monthly_data); + + let countries_data = get_metric_rankings(&conn, "url", &id, "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "country", 5)) + .unwrap_or_default(); + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = get_metric_rankings(&conn, "url", &id, "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "referrer", 5)) + .unwrap_or_default(); + let referrers_chart = generate_bar_chart(&referrers_data); + + let browsers_data = get_metric_rankings(&conn, "url", &id, "browser", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &id, "browser", 5)) + .unwrap_or_default(); + let browsers_chart = generate_bar_chart(&browsers_data); + + let calculated_total_pages = (total_clicks as usize).div_ceil(ANALYTICS_PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.analytics_page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; + + let visits_raw = get_target_visits_paginated( + &conn, + "url", + &id, + ANALYTICS_PAGE_SIZE as i64, + offset as i64, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or_default(); + + let visits: Vec = visits_raw + .into_iter() + .enumerate() + .map(|(idx, r)| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + let sr = offset + idx + 1; + crate::templates::VisitorLogEntry { + sr, + timestamp: r.timestamp, + ip_address: r.ip_address, + country: if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }, + referrer, + browser, + user_agent: r.user_agent, + utm_source: "-".to_string(), + utm_campaign: "-".to_string(), + } + }) + .collect(); + + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + + let page_start = if total_clicks == 0 { 0 } else { offset + 1 }; + let page_end = if total_clicks == 0 { + 0 + } else { + std::cmp::min(total_clicks as usize, offset + ANALYTICS_PAGE_SIZE) + }; + + let template = crate::templates::UrlAnalyticsTemplate { + admin_username: user.username, + url, + total_clicks, + unique_visitors, + qr_scans, + direct_clicks, + traffic_chart, + monthly_chart, + countries_chart, + referrers_chart, + browsers_chart, + visits, + current_page, + total_pages, + visible_pages, + total_records: total_clicks, + page_start, + page_end, + date_from: clean_date_from, + date_to: clean_date_to, + is_admin: false, + }; + + template.into_response() +} + +pub async fn user_page_analytics_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + // Ownership check + let (owner_user_id, target_type) = { + let conn = state.system_db.lock().unwrap(); + match conn.query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + [&id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) { + Ok(val) => val, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return Redirect::to("/user/pages?error=Landing page not found").into_response(); + } + Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), + } + }; + + if owner_user_id != user.id || target_type != "page" { + return StatusCode::FORBIDDEN.into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), + }; + + let page = { + let conn = user_dbs.content.lock().unwrap(); + match crate::db::content::get_landing_page_by_id(&conn, &id) { + Ok(Some(p)) => p, + Ok(None) => { + return Redirect::to("/user/pages?error=Landing page not found").into_response() + } + Err(_) => return Redirect::to("/user/pages?error=Database error").into_response(), + } + }; + + let conn = user_dbs.analytics.lock().unwrap(); + + let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default(); + let has_utm_source = schema_cols.contains("utm_source"); + let has_utm_campaign = schema_cols.contains("utm_campaign"); + if has_utm_source || has_utm_campaign { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "UTM mapping verification failed", + ) + .into_response(); + } + + let (clean_date_from, clean_date_to) = + match validate_date_filters(query.date_from.as_deref(), query.date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; + + let total_views = get_target_visit_total_filtered( + &conn, + "page", + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or(0); + + let unique_visitors = get_target_unique_visitors(&conn, "page", &id).unwrap_or(0); + + let clicks_data = get_clicks_trend(&conn, "page", &id, 30) + .or_else(|_| get_clicks_trend_raw(&conn, "page", &id, 30)) + .unwrap_or_default(); + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let monthly_data = get_monthly_clicks_trend(&conn, "page", &id, 12).unwrap_or_default(); + let monthly_chart = generate_line_chart(&monthly_data); + + let countries_data = get_metric_rankings(&conn, "page", &id, "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "country", 5)) + .unwrap_or_default(); + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = get_metric_rankings(&conn, "page", &id, "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &id, "referrer", 5)) + .unwrap_or_default(); + let referrers_chart = generate_bar_chart(&referrers_data); + + let calculated_total_pages = (total_views as usize).div_ceil(ANALYTICS_PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.analytics_page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * ANALYTICS_PAGE_SIZE; + + let visits_raw = get_target_visits_paginated( + &conn, + "page", + &id, + ANALYTICS_PAGE_SIZE as i64, + offset as i64, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or_default(); + + let visits: Vec = visits_raw + .into_iter() + .enumerate() + .map(|(idx, r)| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + let sr = offset + idx + 1; + crate::templates::VisitorLogEntry { + sr, + timestamp: r.timestamp, + ip_address: r.ip_address, + country: if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }, + referrer, + browser, + user_agent: r.user_agent, + utm_source: "-".to_string(), + utm_campaign: "-".to_string(), + } + }) + .collect(); + + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + + let page_start = if total_views == 0 { 0 } else { offset + 1 }; + let page_end = if total_views == 0 { + 0 + } else { + std::cmp::min(total_views as usize, offset + ANALYTICS_PAGE_SIZE) + }; + + let template = crate::templates::PageAnalyticsTemplate { + admin_username: user.username, + page, + total_views, + unique_visitors, + traffic_chart, + monthly_chart, + countries_chart, + referrers_chart, + visits, + current_page, + total_pages, + visible_pages, + total_records: total_views, + page_start, + page_end, + date_from: clean_date_from, + date_to: clean_date_to, + is_admin: false, + }; + + template.into_response() +} + +pub async fn user_url_analytics_csv_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + // Ownership check + let (owner_user_id, target_type) = { + let conn = state.system_db.lock().unwrap(); + match conn.query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + [&id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) { + Ok(val) => val, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return StatusCode::NOT_FOUND.into_response(); + } + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + }; + + if owner_user_id != user.id || target_type != "url" { + return StatusCode::FORBIDDEN.into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + perform_user_csv_export(user_dbs, "url", id, query.date_from, query.date_to).await +} + +pub async fn user_url_analytics_json_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + // Ownership check + let (owner_user_id, target_type) = { + let conn = state.system_db.lock().unwrap(); + match conn.query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + [&id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) { + Ok(val) => val, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return StatusCode::NOT_FOUND.into_response(); + } + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + }; + + if owner_user_id != user.id || target_type != "url" { + return StatusCode::FORBIDDEN.into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + perform_user_json_export(user_dbs, "url", id, query.date_from, query.date_to).await +} + +pub async fn user_page_analytics_csv_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + // Ownership check + let (owner_user_id, target_type) = { + let conn = state.system_db.lock().unwrap(); + match conn.query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + [&id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) { + Ok(val) => val, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return StatusCode::NOT_FOUND.into_response(); + } + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + }; + + if owner_user_id != user.id || target_type != "page" { + return StatusCode::FORBIDDEN.into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + perform_user_csv_export(user_dbs, "page", id, query.date_from, query.date_to).await +} + +pub async fn user_page_analytics_json_export( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(query): Query, +) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + // Ownership check + let (owner_user_id, target_type) = { + let conn = state.system_db.lock().unwrap(); + match conn.query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1", + [&id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) { + Ok(val) => val, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return StatusCode::NOT_FOUND.into_response(); + } + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + }; + + if owner_user_id != user.id || target_type != "page" { + return StatusCode::FORBIDDEN.into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + perform_user_json_export(user_dbs, "page", id, query.date_from, query.date_to).await +} diff --git a/src/web/admin/api_keys.rs b/src/web/admin/api_keys.rs new file mode 100644 index 0000000..154a5e5 --- /dev/null +++ b/src/web/admin/api_keys.rs @@ -0,0 +1,236 @@ +use super::*; + +#[derive(Deserialize)] +pub struct CreateApiKeyForm { + pub key_name: String, + pub csrf_token: String, +} + +// POST /admin/settings/api-keys/create +pub async fn create_api_key_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + let key_secret = format!("bzo_{}", generate_token(16)); + + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(key_secret.as_bytes()); + let hashed_key = hex::encode(hasher.finalize()); + + let conn = state.admin_db.lock().unwrap(); + match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) { + Ok(api_key) => { + let _ = write_audit_log( + &conn, + &state, + &user.username, + "API_KEY_CREATED", + Some("api_key"), + Some(&api_key.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to(&format!( + "/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}", + key_secret + )).into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response() + } + } +} + +// POST /admin/settings/api-keys/revoke/:id +pub async fn revoke_api_key_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.admin_db.lock().unwrap(); + match delete_api_key(&conn, &id) { + Ok(_) => { + let _ = write_audit_log( + &conn, + &state, + &user.username, + "API_KEY_REVOKED", + Some("api_key"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/admin/settings?success=API Token revoked").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/settings?error=Failed to revoke key: {}", + e + )) + .into_response(), + } +} + +// GET /api-tokens +pub async fn api_tokens_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let tokens = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;", + ) + .unwrap(); + let rows = stmt + .query_map([user.id], |row| { + Ok(crate::models::UserApiToken { + id: row.get(0)?, + user_id: row.get(1)?, + token_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::ApiTokensTemplate { + admin_username: user.username.clone(), + username: user.username, + tokens, + new_token: params.get("new_token").cloned(), + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// POST /api-tokens/create +pub async fn api_tokens_create_post( + State(state): State, + jar: CookieJar, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response(); + } + + use sha2::Digest; + let raw_token = format!("key_{}", generate_token(32)); + let mut hasher = sha2::Sha256::new(); + hasher.update(raw_token.as_bytes()); + let hashed_token = hex::encode(hasher.finalize()); + + { + let conn = state.users_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + let _ = conn.execute( + "INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);", + rusqlite::params![user.id, hashed_token, now], + ); + } + + { + let conn_sys = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &conn_sys, + &user.username, + "API_TOKEN_CREATED", + "api_token", + "new", + None, + ); + } + + Redirect::to(&format!( + "/api-tokens?new_token={}&success=Token generated successfully", + raw_token + )) + .into_response() +} + +// POST /api-tokens/revoke/:id +pub async fn api_tokens_revoke_post( + State(state): State, + jar: CookieJar, + Path(token_id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response(); + } + + { + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute( + "DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;", + [token_id, user.id], + ); + } + + { + let conn_sys = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &conn_sys, + &user.username, + "API_TOKEN_REVOKED", + "api_token", + &token_id.to_string(), + None, + ); + } + + Redirect::to("/api-tokens?success=API token revoked").into_response() +} diff --git a/src/web/admin/audit.rs b/src/web/admin/audit.rs new file mode 100644 index 0000000..94a46bb --- /dev/null +++ b/src/web/admin/audit.rs @@ -0,0 +1,126 @@ +use super::*; + +// GET /admin/audit +pub async fn audit_get(State(state): State, jar: CookieJar) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let logs = { + let conn = state.system_db.lock().unwrap(); + let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None) + .unwrap_or_default(); + events + .into_iter() + .map(|e| { + let (ip, ua) = if let Some(ref m) = e.metadata { + if m.starts_with("IP: ") { + let parts: Vec<&str> = m.split(", UA: ").collect(); + let ip = parts[0] + .trim_start_matches("IP: ") + .trim_matches('"') + .trim_matches('\'') + .replace("Some(", "") + .replace(")", ""); + let ua = if parts.len() > 1 { + parts[1] + .trim_matches('"') + .trim_matches('\'') + .replace("Some(", "") + .replace(")", "") + } else { + "Unknown".to_string() + }; + (Some(ip), Some(ua)) + } else { + (None, None) + } + } else { + (None, None) + }; + + crate::models::AuditLog { + id: e.id, + timestamp: e.timestamp, + username: e.actor, + action: e.action, + object_type: Some(e.object_type), + object_id: Some(e.object_id), + ip_address: ip, + user_agent: ua, + } + }) + .collect() + }; + + let template = crate::templates::AuditTemplate { + admin_username: user.username, + logs, + }; + + template.into_response() +} + +// GET /user/audit +pub async fn user_audit_get(State(state): State, jar: CookieJar) -> Response { + let (user, _) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let logs = { + let conn = state.system_db.lock().unwrap(); + let events = + crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None) + .unwrap_or_default(); + events + .into_iter() + .map(|e| { + let (ip, ua) = if let Some(ref m) = e.metadata { + if m.starts_with("IP: ") { + let parts: Vec<&str> = m.split(", UA: ").collect(); + let ip = parts[0] + .trim_start_matches("IP: ") + .trim_matches('"') + .trim_matches('\'') + .replace("Some(", "") + .replace(")", ""); + let ua = if parts.len() > 1 { + parts[1] + .trim_matches('"') + .trim_matches('\'') + .replace("Some(", "") + .replace(")", "") + } else { + "Unknown".to_string() + }; + (Some(ip), Some(ua)) + } else { + (None, None) + } + } else { + (None, None) + }; + + crate::models::AuditLog { + id: e.id, + timestamp: e.timestamp, + username: e.actor, + action: e.action, + object_type: Some(e.object_type), + object_id: Some(e.object_id), + ip_address: ip, + user_agent: ua, + } + }) + .collect() + }; + + let template = crate::templates::UserAuditTemplate { + admin_username: user.username, + logs, + }; + + template.into_response() +} diff --git a/src/web/admin/auth.rs b/src/web/admin/auth.rs new file mode 100644 index 0000000..65b6d64 --- /dev/null +++ b/src/web/admin/auth.rs @@ -0,0 +1,517 @@ +use super::*; + +// GET /admin +pub async fn admin_index(State(state): State, jar: CookieJar) -> Response { + match require_auth(&state, &jar).await { + Ok(_) => Redirect::to("/admin/dashboard").into_response(), + Err(redir) => redir.into_response(), + } +} + +// GET /admin/login +pub async fn login_get( + State(state): State, + jar: CookieJar, + headers: axum::http::HeaderMap, + Query(params): Query>, +) -> Response { + let error = params.get("error").cloned(); + let csrf_token = generate_token(16); + + let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); + let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone())) + .path("/admin/login") + .secure(secure_flag) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::minutes(10)) + .build(); + + let new_jar = jar.add(cookie); + let template = crate::templates::LoginTemplate { + error, + csrf_token, + action: "/admin/login".to_string(), + title: "Admin Login".to_string(), + subtitle: "Administrative Access".to_string(), + button_text: "Sign In".to_string(), + }; + (new_jar, template).into_response() +} + +#[derive(Deserialize)] +pub struct LoginForm { + pub username: String, + pub password: String, + pub csrf_token: String, +} + +/// Bootstrap is allowed only when the system has no real admin yet. +pub(crate) fn is_bootstrap_allowed( + user_count: i64, + admin_count: i64, + active_session_count: i64, +) -> bool { + user_count <= 1 && admin_count == 0 && active_session_count == 0 +} + +fn count_login_bootstrap_state( + conn: &rusqlite::Connection, +) -> Result<(i64, i64, i64), rusqlite::Error> { + let u_count: i64 = conn.query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0))?; + let a_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM users WHERE account_type = 'admin';", + [], + |r| r.get(0), + )?; + let now = Utc::now().to_rfc3339(); + let s_count: i64 = conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;", + [now], + |r| r.get(0), + )?; + Ok((u_count, a_count, s_count)) +} + +/// Verify an existing admin tenant user may log into the admin UI. +/// +/// Does not log the password. Rejection reasons are structured for observability. +pub(crate) fn verify_admin_credentials( + user: &crate::models::TenantUser, + password: &str, +) -> Result<(), &'static str> { + if user.status != "active" { + return Err("account_disabled"); + } + if user.account_type != "admin" { + return Err("insufficient_privileges"); + } + if !verify_password(password, &user.password_hash) { + return Err("invalid_credentials"); + } + Ok(()) +} + +fn load_tenant_user_by_username( + conn: &rusqlite::Connection, + username: &str, +) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata + FROM users WHERE username = ?1;", + [username], + |row| { + Ok(crate::models::TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + }, + ) + .optional() +} + +fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User { + User { + id: u.id.to_string(), + username: u.username, + password_hash: u.password_hash, + created_at: u.created_at, + } +} + +fn audit_meta(ip: &str, headers: &HeaderMap) -> String { + format!( + "IP: {:?}, UA: {:?}", + ip, + headers.get("user-agent").and_then(|h| h.to_str().ok()) + ) +} + +// POST /admin/login +pub async fn login_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let temp_csrf = jar + .get("bzod_temp_csrf") + .map(|c| c.value().to_string()) + .unwrap_or_default(); + if temp_csrf.is_empty() || temp_csrf != form.csrf_token { + return Redirect::to("/admin/login?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let bootstrap_allowed = { + let conn = match state.users_db.lock() { + Ok(c) => c, + Err(_) => { + return Redirect::to("/admin/login?error=Internal error").into_response(); + } + }; + match count_login_bootstrap_state(&conn) { + Ok((u, a, s)) => is_bootstrap_allowed(u, a, s), + Err(e) => { + tracing::error!(error = %e, "login bootstrap state query failed"); + false + } + } + }; + + let user_opt = if bootstrap_allowed + && form.username == state.config.admin_username + && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) + { + // Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256 + let hash = match hash_password(&form.password) { + Ok(h) => h, + Err(_) => { + return Redirect::to("/admin/login?error=Internal hashing error").into_response() + } + }; + + let conn = match state.users_db.lock() { + Ok(c) => c, + Err(_) => { + return Redirect::to("/admin/login?error=Internal error").into_response(); + } + }; + match crate::db::users::create_admin_user(&conn, &form.username, &hash) { + Ok(u) => { + if let Err(e) = state.db.init_user_databases(u.id) { + tracing::error!( + "Failed to init user databases during admin bootstrap: {:?}", + e + ); + } + + if let Ok(system_conn) = state.system_db.lock() { + let metadata = audit_meta(&ip, &headers); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &u.username, + "BOOTSTRAP_USER_PROVISIONED", + "user", + &u.id.to_string(), + Some(&metadata), + ); + } + + Some(User { + id: u.id.to_string(), + username: u.username, + password_hash: u.password_hash, + created_at: u.created_at, + }) + } + Err(e) => { + tracing::error!("Failed to create admin user during bootstrap: {:?}", e); + None + } + } + } else { + let conn = match state.users_db.lock() { + Ok(c) => c, + Err(_) => { + return Redirect::to("/admin/login?error=Internal error").into_response(); + } + }; + match load_tenant_user_by_username(&conn, &form.username) { + Ok(Some(u)) => match verify_admin_credentials(&u, &form.password) { + Ok(()) => Some(tenant_user_to_admin_user(u)), + Err(reason) => { + tracing::warn!(username = form.username, reason, "login rejected"); + None + } + }, + Ok(None) => { + tracing::warn!( + username = form.username, + reason = "user_not_found", + "login rejected" + ); + None + } + Err(e) => { + tracing::error!(error = %e, "login user lookup failed"); + None + } + } + }; + + match user_opt { + Some(user) => { + let session_token = generate_token(32); + let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); + + { + let conn = match state.users_db.lock() { + Ok(c) => c, + Err(_) => { + return Redirect::to("/admin/login?error=Internal error").into_response(); + } + }; + let user_id_i64 = user.id.parse::().unwrap_or(0); + let now = Utc::now().to_rfc3339(); + if let Err(e) = conn.execute( + "INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", + rusqlite::params![session_token, user_id_i64, expires, now], + ) { + tracing::error!(error = %e, "failed to insert admin session"); + return Redirect::to("/admin/login?error=Internal error").into_response(); + } + + if let Ok(system_conn) = state.system_db.lock() { + let metadata = audit_meta(&ip, &headers); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "USER_LOGIN", + "session", + &session_token, + Some(&metadata), + ); + } + } + + let secure_flag = + crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); + let cookie = Cookie::build(("bzod_session", session_token)) + .path("/") + .secure(secure_flag) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::days(30)) + .build(); + + let clear_temp = Cookie::build("bzod_temp_csrf") + .path("/admin/login") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = response_jar.add(cookie).add(clear_temp); + + (response_jar, Redirect::to("/admin/dashboard")).into_response() + } + None => { + if let Ok(system_conn) = state.system_db.lock() { + let metadata = audit_meta(&ip, &headers); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + "anonymous", + "LOGIN_FAILED", + "login", + "", + Some(&metadata), + ); + } + Redirect::to("/admin/login?error=Invalid username or password").into_response() + } + } +} + +// GET /logout +pub async fn public_logout(State(_state): State, jar: CookieJar) -> Response { + let cookie = Cookie::build("bzod_user_session") + .path("/") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = response_jar.add(cookie); + + (response_jar, Redirect::to("/login")).into_response() +} + +// GET /login +pub async fn public_login_get( + State(state): State, + jar: CookieJar, + headers: axum::http::HeaderMap, + Query(params): Query>, +) -> Response { + let error = params.get("error").cloned(); + let csrf_token = generate_token(16); + + let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); + let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone())) + .path("/login") + .secure(secure_flag) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::minutes(10)) + .build(); + + let new_jar = jar.add(cookie); + let template = crate::templates::LoginTemplate { + error, + csrf_token, + action: "/login".to_string(), + title: "User Login".to_string(), + subtitle: "Standard account access".to_string(), + button_text: "Sign In".to_string(), + }; + (new_jar, template).into_response() +} + +// POST /login +pub async fn public_login_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let temp_csrf = jar + .get("bzod_temp_csrf") + .map(|c| c.value().to_string()) + .unwrap_or_default(); + if temp_csrf.is_empty() || temp_csrf != form.csrf_token { + return Redirect::to("/login?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let user_opt: Option = { + let conn = state.users_db.lock().unwrap(); + let user_res: Result, rusqlite::Error> = conn.query_row( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ + FROM users WHERE username = ?1;", + [&form.username], + |row| { + Ok(crate::models::TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + } + ).optional(); + + match user_res { + Ok(Some(u)) => { + if u.status != "active" { + None + } else if verify_password(&form.password, &u.password_hash) { + Some(u) + } else { + None + } + } + _ => None, + } + }; + + match user_opt { + Some(user) => { + let session_token = generate_token(32); + let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); + + { + let conn = state.users_db.lock().unwrap(); + let _ = + crate::db::users::create_user_session(&conn, &session_token, user.id, &expires); + + let system_conn = state.system_db.lock().unwrap(); + let metadata = format!( + "IP: {:?}, UA: {:?}", + ip, + headers.get("user-agent").and_then(|h| h.to_str().ok()) + ); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "USER_LOGIN", + "session", + &session_token, + Some(&metadata), + ); + } + + let secure_flag = + crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); + let cookie = Cookie::build(("bzod_user_session", session_token)) + .path("/") + .secure(secure_flag) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::days(30)) + .build(); + + let clear_temp = Cookie::build("bzod_temp_csrf") + .path("/login") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = response_jar.add(cookie).add(clear_temp); + + (response_jar, Redirect::to("/user/dashboard")).into_response() + } + None => { + let system_conn = state.system_db.lock().unwrap(); + let metadata = format!( + "IP: {:?}, UA: {:?}", + ip, + headers.get("user-agent").and_then(|h| h.to_str().ok()) + ); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + "anonymous", + "LOGIN_FAILED", + "login", + "", + Some(&metadata), + ); + Redirect::to("/login?error=Invalid username or password").into_response() + } + } +} + +// GET /admin/logout +pub async fn logout(State(state): State, jar: CookieJar) -> Response { + if let Ok((_, session_id)) = require_auth(&state, &jar).await { + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]); + } + + let cookie = Cookie::build("bzod_session") + .path("/") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = response_jar.add(cookie); + + (response_jar, Redirect::to("/admin/login")).into_response() +} + +#[cfg(test)] +mod login_helpers_tests { + use super::is_bootstrap_allowed; + + #[test] + fn bootstrap_only_when_no_admin() { + assert!(is_bootstrap_allowed(0, 0, 0)); + assert!(is_bootstrap_allowed(1, 0, 0)); + assert!(!is_bootstrap_allowed(2, 0, 0)); + assert!(!is_bootstrap_allowed(1, 1, 0)); + assert!(!is_bootstrap_allowed(1, 0, 1)); + } +} diff --git a/src/web/admin/backups.rs b/src/web/admin/backups.rs new file mode 100644 index 0000000..bc3f8d7 --- /dev/null +++ b/src/web/admin/backups.rs @@ -0,0 +1,300 @@ +use super::*; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct BackupFileRow { + pub filename: String, + pub size_str: String, + pub created_str: String, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct BackupHistoryRow { + pub id: String, + pub backup_path: String, + pub status: String, + pub created_at: String, + pub size_bytes: i64, + pub error_message: Option, +} + +// GET /admin/backups +pub async fn backups_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let mut files = vec![]; + if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) { + for entry in dir_entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(filename) = path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + { + if filename.ends_with(".tar.gz") { + let meta = entry.metadata().unwrap(); + let size_str = format_size(meta.len()); + let created_str = meta + .created() + .ok() + .map(|c| { + let datetime: chrono::DateTime = c.into(); + datetime.format("%Y-%m-%d %H:%M:%S").to_string() + }) + .unwrap_or_else(|| "-".to_string()); + files.push(BackupFileRow { + filename, + size_str, + created_str, + }); + } + } + } + } + } + files.sort_by(|a, b| b.filename.cmp(&a.filename)); + + let history = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(BackupHistoryRow { + id: row.get(0)?, + backup_path: row.get(1)?, + status: row.get(2)?, + created_at: row.get(3)?, + size_bytes: row.get(4)?, + error_message: row.get(5)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::BackupsTemplate { + admin_username: user.username, + files, + history, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// POST /admin/backups/create +pub async fn backups_create_post( + State(state): State, + jar: CookieJar, + Form(form): Form>, +) -> Response { + let (_user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); + } + + match crate::jobs::backup::perform_backup(&state.db, &state.config).await { + Ok(path) => { + let filename = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("backup.tar.gz"); + Redirect::to(&format!( + "/admin/backups?success=Backup created successfully: {}", + filename + )) + .into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/backups?error=Failed to generate backup: {}", + e + )) + .into_response(), + } +} + +// GET /admin/backups/download/:filename +pub async fn backups_download_get( + State(state): State, + jar: CookieJar, + Path(filename): Path, +) -> Response { + if require_auth(&state, &jar).await.is_err() { + return StatusCode::UNAUTHORIZED.into_response(); + } + + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return StatusCode::BAD_REQUEST.into_response(); + } + + let backup_path = state.config.backup_dir.join(&filename); + if !backup_path.exists() { + return StatusCode::NOT_FOUND.into_response(); + } + + match std::fs::read(&backup_path) { + Ok(bytes) => { + let body = axum::body::Body::from(bytes); + Response::builder() + .header("content-type", "application/octet-stream") + .header( + "content-disposition", + format!("attachment; filename=\"{}\"", filename), + ) + .body(body) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +// POST /admin/backups/delete/:filename +pub async fn backups_delete_post( + State(state): State, + jar: CookieJar, + Path(filename): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); + } + + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Redirect::to("/admin/backups?error=Invalid filename").into_response(); + } + + let backup_path = state.config.backup_dir.join(&filename); + if !backup_path.exists() { + return Redirect::to("/admin/backups?error=Backup file not found").into_response(); + } + + match std::fs::remove_file(&backup_path) { + Ok(_) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "BACKUP_DELETE", + "backup", + &filename, + None, + ); + Redirect::to("/admin/backups?success=Backup archive deleted").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/backups?error=Failed to delete backup file: {}", + e + )) + .into_response(), + } +} + +// POST /admin/backups/restore +pub async fn backups_restore_post( + State(state): State, + jar: CookieJar, + mut multipart: axum::extract::Multipart, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let mut backup_bytes = vec![]; + let mut confirm_text = String::new(); + let mut csrf_token = String::new(); + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or_default().to_string(); + if name == "backup_file" { + if let Ok(bytes) = field.bytes().await { + backup_bytes = bytes.to_vec(); + } + } else if name == "confirm_text" { + if let Ok(text) = field.text().await { + confirm_text = text.trim().to_string(); + } + } else if name == "csrf_token" { + if let Ok(text) = field.text().await { + csrf_token = text.trim().to_string(); + } + } + } + + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response(); + } + + if confirm_text != "RESTORE" { + return Redirect::to( + "/admin/backups?error=Confirmation text mismatch. Please type RESTORE.", + ) + .into_response(); + } + + if backup_bytes.is_empty() { + return Redirect::to("/admin/backups?error=Backup file is empty or missing.") + .into_response(); + } + + let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz"); + if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) { + return Redirect::to(&format!( + "/admin/backups?error=Failed to save uploaded file: {}", + e + )) + .into_response(); + } + + match crate::cli::restore::run( + temp_file_path.to_string_lossy().to_string(), + None, + state.config.clone(), + ) + .await + { + Ok(_) => { + let _ = std::fs::remove_file(&temp_file_path); + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute("DELETE FROM sessions;", []); + + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "RESTORE_EXECUTION", + "backup", + "upload", + None, + ); + Redirect::to("/admin/login?success=Restore successful. Please log in again.") + .into_response() + } + Err(e) => { + let _ = std::fs::remove_file(&temp_file_path); + Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response() + } + } +} diff --git a/src/web/admin/dashboard.rs b/src/web/admin/dashboard.rs new file mode 100644 index 0000000..8a0600b --- /dev/null +++ b/src/web/admin/dashboard.rs @@ -0,0 +1,170 @@ +use super::*; + +// GET /user/dashboard +pub async fn user_dashboard_get(State(state): State, jar: CookieJar) -> Response { + let (user, _session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let (total_urls, active_links, dead_links) = { + let conn = user_dbs.content.lock().unwrap(); + get_url_counts(&conn).unwrap_or((0, 0, 0)) + }; + + let total_pages = { + let conn = user_dbs.content.lock().unwrap(); + get_landing_page_count(&conn).unwrap_or(0) + }; + + let total_clicks = { + let conn = user_dbs.analytics.lock().unwrap(); + get_total_clicks(&conn).unwrap_or(0) + }; + + let clicks_data = { + let conn = user_dbs.analytics.lock().unwrap(); + get_clicks_trend(&conn, "url", "all", 30) + .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) + .unwrap_or_default() + }; + + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let countries_data = { + let conn = user_dbs.analytics.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) + .unwrap_or_default() + }; + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = { + let conn = user_dbs.analytics.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) + .unwrap_or_default() + }; + let referrers_chart = generate_bar_chart(&referrers_data); + + let browsers_data = { + let conn = user_dbs.analytics.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "browser", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) + .unwrap_or_default() + }; + let browsers_chart = generate_bar_chart(&browsers_data); + + let template = crate::templates::UserDashboardTemplate { + admin_username: user.username, + total_urls, + total_pages, + total_clicks, + active_links, + dead_links, + traffic_chart, + countries_chart, + browsers_chart, + referrers_chart, + }; + + template.into_response() +} + +// GET /admin/dashboard +pub async fn dashboard_get(State(state): State, jar: CookieJar) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let (total_urls, active_links, dead_links) = { + let conn = state.content_db.lock().unwrap(); + get_url_counts(&conn).unwrap_or((0, 0, 0)) + }; + + let total_pages = { + let conn = state.content_db.lock().unwrap(); + get_landing_page_count(&conn).unwrap_or(0) + }; + + let total_clicks = { + let conn = state.analytics_db.lock().unwrap(); + get_total_clicks(&conn).unwrap_or(0) + }; + + let clicks_data = { + let conn = state.analytics_db.lock().unwrap(); + get_clicks_trend(&conn, "url", "all", 30) + .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) + .unwrap_or_default() + }; + + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)) + .format("%Y-%m-%d") + .to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let countries_data = { + let conn = state.analytics_db.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) + .unwrap_or_default() + }; + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = { + let conn = state.analytics_db.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) + .unwrap_or_default() + }; + let referrers_chart = generate_bar_chart(&referrers_data); + + let browsers_data = { + let conn = state.analytics_db.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "browser", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) + .unwrap_or_default() + }; + let browsers_chart = generate_bar_chart(&browsers_data); + + let template = crate::templates::DashboardTemplate { + admin_username: user.username, + total_urls, + total_pages, + total_clicks, + active_links, + dead_links, + traffic_chart, + countries_chart, + browsers_chart, + referrers_chart, + }; + + template.into_response() +} diff --git a/src/web/admin/health.rs b/src/web/admin/health.rs new file mode 100644 index 0000000..8d215af --- /dev/null +++ b/src/web/admin/health.rs @@ -0,0 +1,169 @@ +use super::*; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct JobHistoryRow { + pub id: String, + pub job_name: String, + pub status: String, + pub started_at: String, + pub finished_at: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct HealthCheckRow { + pub id: String, + pub object_type: String, + pub object_id: String, + pub checked_at: String, + pub status_code: Option, + pub error_message: Option, + pub is_healthy: i64, +} + +// GET /admin/health +pub async fn health_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let mut db_reports = vec![]; + if let Ok(r) = + crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin") + { + db_reports.push(r); + } + if let Ok(r) = + crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system") + { + db_reports.push(r); + } + if let Ok(r) = + crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users") + { + db_reports.push(r); + } + + let system_db_path = state.config.data_dir.join("admin").join("system.db"); + let users_db_path = state.config.data_dir.join("admin").join("users.db"); + let admin_db_path = state.config.data_dir.join("admin").join("admin.db"); + + let system_db_size = format_size( + std::fs::metadata(&system_db_path) + .map(|m| m.len()) + .unwrap_or(0), + ); + let users_db_size = format_size( + std::fs::metadata(&users_db_path) + .map(|m| m.len()) + .unwrap_or(0), + ); + let admin_db_size = format_size( + std::fs::metadata(&admin_db_path) + .map(|m| m.len()) + .unwrap_or(0), + ); + + let users_dir = state.config.data_dir.join("users"); + let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0)); + let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0)); + + let job_history = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(JobHistoryRow { + id: row.get(0)?, + job_name: row.get(1)?, + status: row.get(2)?, + started_at: row.get(3)?, + finished_at: row.get(4)?, + error_message: row.get(5)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let health_checks = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(HealthCheckRow { + id: row.get(0)?, + object_type: row.get(1)?, + object_id: row.get(2)?, + checked_at: row.get(3)?, + status_code: row.get(4)?, + error_message: row.get(5)?, + is_healthy: row.get(6)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let (registry_errors, registry_warnings) = { + let system_conn = state.system_db.lock().unwrap(); + let users_conn = state.users_db.lock().unwrap(); + match crate::services::registry_validator::RegistryValidator::scan( + &system_conn, + &users_conn, + &state.config.data_dir, + None, + ) { + Ok(issues) => { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + for issue in issues { + use crate::services::registry_validator::RegistryIssueType; + match issue.issue_type { + RegistryIssueType::StaleReservation + | RegistryIssueType::TenantAdminHasIsolatedContent => { + warnings.push(format!( + "Warning for slug {}: {}", + issue.slug, issue.description + )); + } + _ => { + errors.push(format!( + "Error for slug {}: {}", + issue.slug, issue.description + )); + } + } + } + (errors, warnings) + } + Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]), + } + }; + + let template = crate::templates::HealthTemplate { + admin_username: user.username, + db_reports, + total_data_size, + system_db_size, + users_db_size, + admin_db_size, + tenants_db_size, + job_history, + health_checks, + registry_errors, + registry_warnings, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} diff --git a/src/web/admin/mod.rs b/src/web/admin/mod.rs new file mode 100644 index 0000000..9438b3f --- /dev/null +++ b/src/web/admin/mod.rs @@ -0,0 +1,849 @@ +//! Admin web UI handlers (feature-split modules). +//! +//! Handlers are organized by domain; shared auth, audit, export, and helpers +//! live in this module root so child modules can access them via `super`. + +use crate::auth::{ + authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token, + hash_password, verify_csrf, verify_password, verify_sha256, +}; +use crate::charts::{generate_bar_chart, generate_line_chart}; +use crate::db::admin::{ + create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config, + write_audit_log as write_audit_log_legacy, +}; +use crate::db::analytics::{ + clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, + get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors, + get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated, + get_total_clicks, get_visits_schema_columns, parse_ua, +}; +use crate::db::content::{ + create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, + get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts, + list_landing_pages, list_urls, +}; +use crate::models::User; +use crate::state::AppState; +use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage}; +use axum::{ + extract::{ConnectInfo, Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Redirect, Response}, + Form, +}; +use axum_extra::extract::cookie::Cookie; +use axum_extra::extract::CookieJar; +use chrono::Utc; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use flate2::Compression; +use rusqlite::{params, OptionalExtension}; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs::File; +use std::net::SocketAddr; +use tar::Builder; +use uuid::Uuid; + +// --- Shared constants, auth, audit, and export helpers --- + +#[allow(clippy::too_many_arguments)] +pub(crate) fn write_audit_log( + conn: &rusqlite::Connection, + state: &AppState, + username: &str, + action: &str, + object_type: Option<&str>, + object_id: Option<&str>, + ip_address: Option<&str>, + user_agent: Option<&str>, +) -> rusqlite::Result { + let res = write_audit_log_legacy( + conn, + username, + action, + object_type, + object_id, + ip_address, + user_agent, + ); + + // Also write to unified audit events in system.db + let system_conn = state.system_db.lock().unwrap(); + let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + username, + action, + object_type.unwrap_or(""), + object_id.unwrap_or(""), + Some(&metadata), + ); + + res +} +pub(crate) const PAGE_SIZE: usize = 25; +pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50; +pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000; +// Helper: Verify admin session and return user or redirect to login +pub(crate) async fn require_auth( + state: &AppState, + jar: &CookieJar, +) -> Result<(User, String), Redirect> { + let conn = match state.users_db.lock() { + Ok(c) => c, + Err(_) => return Err(Redirect::to("/admin/login")), + }; + match authenticate_admin_session(&conn, jar) { + Ok(Some((user, session_id))) => Ok((user, session_id)), + _ => Err(Redirect::to("/admin/login")), + } +} +// Helper: Verify tenant user session and return user or redirect to login +pub(crate) async fn require_user_auth( + state: &AppState, + jar: &CookieJar, +) -> Result<(crate::models::TenantUser, String), Redirect> { + let conn = match state.users_db.lock() { + Ok(c) => c, + Err(_) => return Err(Redirect::to("/login")), + }; + match authenticate_user_session(&conn, jar) { + Ok(Some((user, session_id))) => Ok((user, session_id)), + _ => Err(Redirect::to("/login")), + } +} +#[derive(Deserialize)] +pub struct AnalyticsQuery { + pub analytics_page: Option, + pub date_from: Option, + pub date_to: Option, +} +pub(crate) fn validate_date_filters( + date_from: Option<&str>, + date_to: Option<&str>, +) -> Result<(Option, Option), StatusCode> { + let from_parsed = match date_from { + Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") { + Ok(d) => Some(d), + Err(_) => return Err(StatusCode::BAD_REQUEST), + }, + _ => None, + }; + let to_parsed = match date_to { + Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { + Ok(d) => Some(d), + Err(_) => return Err(StatusCode::BAD_REQUEST), + }, + _ => None, + }; + if let (Some(f), Some(t)) = (from_parsed, to_parsed) { + if f > t { + return Err(StatusCode::BAD_REQUEST); + } + } + Ok(( + from_parsed.map(|d| d.format("%Y-%m-%d").to_string()), + to_parsed.map(|d| d.format("%Y-%m-%d").to_string()), + )) +} +pub(crate) fn escape_csv_field(field: &str) -> String { + let needs_escaping = + field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r'); + if needs_escaping { + let escaped = field.replace('"', "\"\""); + format!("\"{}\"", escaped) + } else { + field.to_string() + } +} +pub(crate) struct DbExportStream { + receiver: tokio::sync::mpsc::Receiver>, +} + +impl futures_util::stream::Stream for DbExportStream { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.receiver.poll_recv(cx) + } +} + +pub(crate) async fn perform_csv_export( + state: AppState, + target_type: &'static str, + id: String, + date_from: Option, + date_to: Option, +) -> Response { + let (clean_date_from, clean_date_to) = + match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; + + let target_exists = { + let conn = state.content_db.lock().unwrap(); + if target_type == "url" { + get_url_by_id(&conn, &id) + .map(|u| u.is_some()) + .unwrap_or(false) + } else { + get_landing_page_by_id(&conn, &id) + .map(|p| p.is_some()) + .unwrap_or(false) + } + }; + if !target_exists { + return (StatusCode::NOT_FOUND, "Target not found").into_response(); + } + + let count = { + let conn = state.analytics_db.lock().unwrap(); + get_target_visit_total_filtered( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or(0) + }; + + let (has_utm_source, has_utm_campaign) = { + let conn = state.analytics_db.lock().unwrap(); + let cols = get_visits_schema_columns(&conn).unwrap_or_default(); + (cols.contains("utm_source"), cols.contains("utm_campaign")) + }; + + let (tx, rx) = + tokio::sync::mpsc::channel::>(32); + let analytics_db = state.analytics_db.clone(); + let target_id = id.clone(); + + tokio::task::spawn_blocking(move || { + let conn = analytics_db.lock().unwrap(); + + let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string(); + if has_utm_source { + header.push_str(",UTM Source"); + } + if has_utm_campaign { + header.push_str(",UTM Campaign"); + } + header.push('\n'); + + if tx + .blocking_send(Ok(axum::body::Bytes::from(header))) + .is_err() + { + return; + } + + let select_fields = if has_utm_source && has_utm_campaign { + "timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign" + } else if has_utm_source { + "timestamp, ip_address, country, referer, user_agent, utm_source" + } else if has_utm_campaign { + "timestamp, ip_address, country, referer, user_agent, utm_campaign" + } else { + "timestamp, ip_address, country, referer, user_agent" + }; + + let mut sql = format!( + "SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2", + select_fields + ); + let mut params: Vec> = + vec![Box::new(target_type.to_string()), Box::new(target_id)]; + + if let Some(df) = clean_date_from.as_deref() { + sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); + params.push(Box::new(format!("{}T00:00:00Z", df))); + } + + if let Some(dt) = clean_date_to.as_deref() { + if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { + let next_day = parsed_date + chrono::Duration::days(1); + sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); + params.push(Box::new(format!( + "{}T00:00:00Z", + next_day.format("%Y-%m-%d") + ))); + } + } + + sql.push_str(" ORDER BY timestamp DESC, id DESC"); + + let mut stmt = match conn.prepare(&sql) { + Ok(s) => s, + Err(_) => return, + }; + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) { + Ok(r) => r, + Err(_) => return, + }; + + let mut csv_buffer = String::new(); + + while let Ok(Some(row)) = rows.next() { + let timestamp: String = row.get(0).unwrap_or_default(); + let ip_address: String = row.get(1).unwrap_or_default(); + let country: String = row.get(2).unwrap_or_default(); + let referer: String = row.get(3).unwrap_or_default(); + let user_agent: String = row.get(4).unwrap_or_default(); + + let (browser, _, _) = parse_ua(&user_agent); + let referrer = clean_referrer(&referer); + let country_display = if country.is_empty() { + "Unknown".to_string() + } else { + country + }; + + let mut line = format!( + "{},{},{},{},{},{}", + escape_csv_field(×tamp), + escape_csv_field(&ip_address), + escape_csv_field(&country_display), + escape_csv_field(&referrer), + escape_csv_field(&browser), + escape_csv_field(&user_agent) + ); + + let mut col_idx = 5; + if has_utm_source { + let utm_src: String = row.get(col_idx).unwrap_or_default(); + line.push_str(&format!(",{}", escape_csv_field(&utm_src))); + col_idx += 1; + } + if has_utm_campaign { + let utm_camp: String = row.get(col_idx).unwrap_or_default(); + line.push_str(&format!(",{}", escape_csv_field(&utm_camp))); + } + line.push('\n'); + + csv_buffer.push_str(&line); + if csv_buffer.len() >= 8192 { + let bytes = axum::body::Bytes::from(csv_buffer); + if tx.blocking_send(Ok(bytes)).is_err() { + return; + } + csv_buffer = String::new(); + } + } + + if !csv_buffer.is_empty() { + let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer))); + } + }); + + let stream = DbExportStream { receiver: rx }; + let filename = if target_type == "url" { + format!("url_{}_analytics.csv", id) + } else { + format!("page_{}_analytics.csv", id) + }; + + ( + StatusCode::OK, + [ + ("Content-Type", "text/csv"), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), + ("X-BZOD-Export-Records", &count.to_string()), + ], + axum::body::Body::from_stream(stream), + ) + .into_response() +} +pub(crate) async fn perform_json_export( + state: AppState, + target_type: &'static str, + id: String, + date_from: Option, + date_to: Option, +) -> Response { + let (clean_date_from, clean_date_to) = + match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; + + let target_exists = { + let conn = state.content_db.lock().unwrap(); + if target_type == "url" { + get_url_by_id(&conn, &id) + .map(|u| u.is_some()) + .unwrap_or(false) + } else { + get_landing_page_by_id(&conn, &id) + .map(|p| p.is_some()) + .unwrap_or(false) + } + }; + if !target_exists { + return (StatusCode::NOT_FOUND, "Target not found").into_response(); + } + + let count = { + let conn = state.analytics_db.lock().unwrap(); + get_target_visit_total_filtered( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or(0) + }; + + if count > MAX_JSON_EXPORT_ROWS as i64 { + return StatusCode::PAYLOAD_TOO_LARGE.into_response(); + } + + let visits_raw = { + let conn = state.analytics_db.lock().unwrap(); + match get_target_visits_all_in_memory( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) { + Ok(v) => v, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + + #[derive(serde::Serialize)] + struct JsonExportRow { + timestamp: String, + ip_address: String, + country: String, + referrer: String, + browser: String, + user_agent: String, + } + + let export_rows: Vec = visits_raw + .into_iter() + .map(|r| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + let country_display = if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }; + JsonExportRow { + timestamp: r.timestamp, + ip_address: r.ip_address, + country: country_display, + referrer, + browser, + user_agent: r.user_agent, + } + }) + .collect(); + + let body_str = match serde_json::to_string(&export_rows) { + Ok(s) => s, + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response() + } + }; + + let filename = if target_type == "url" { + format!("url_{}_analytics.json", id) + } else { + format!("page_{}_analytics.json", id) + }; + + ( + StatusCode::OK, + [ + ("Content-Type", "application/json"), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), + ("X-BZOD-Export-Records", &count.to_string()), + ], + body_str, + ) + .into_response() +} +// Helpers +pub(crate) fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.2} KB", bytes as f64 / 1024.0) + } else { + format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} +pub(crate) fn get_dir_size(dir: &std::path::Path) -> std::io::Result { + let mut total = 0; + if dir.is_dir() { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + total += get_dir_size(&path)?; + } else { + total += entry.metadata()?.len(); + } + } + } + Ok(total) +} +pub(crate) async fn perform_user_csv_export( + user_dbs: crate::state::UserDbs, + target_type: &'static str, + id: String, + date_from: Option, + date_to: Option, +) -> Response { + let (clean_date_from, clean_date_to) = + match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; + + let target_exists = { + let conn = user_dbs.content.lock().unwrap(); + if target_type == "url" { + get_url_by_id(&conn, &id) + .map(|u| u.is_some()) + .unwrap_or(false) + } else { + get_landing_page_by_id(&conn, &id) + .map(|p| p.is_some()) + .unwrap_or(false) + } + }; + if !target_exists { + return (StatusCode::NOT_FOUND, "Target not found").into_response(); + } + + let count = { + let conn = user_dbs.analytics.lock().unwrap(); + get_target_visit_total_filtered( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or(0) + }; + + let (has_utm_source, has_utm_campaign) = { + let conn = user_dbs.analytics.lock().unwrap(); + let cols = get_visits_schema_columns(&conn).unwrap_or_default(); + (cols.contains("utm_source"), cols.contains("utm_campaign")) + }; + + let (tx, rx) = + tokio::sync::mpsc::channel::>(32); + let analytics_db = user_dbs.analytics.clone(); + let target_id = id.clone(); + + tokio::task::spawn_blocking(move || { + let conn = analytics_db.lock().unwrap(); + + let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string(); + if has_utm_source { + header.push_str(",UTM Source"); + } + if has_utm_campaign { + header.push_str(",UTM Campaign"); + } + header.push('\n'); + + if tx + .blocking_send(Ok(axum::body::Bytes::from(header))) + .is_err() + { + return; + } + + let select_fields = if has_utm_source && has_utm_campaign { + "timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign" + } else if has_utm_source { + "timestamp, ip_address, country, referer, user_agent, utm_source" + } else if has_utm_campaign { + "timestamp, ip_address, country, referer, user_agent, utm_campaign" + } else { + "timestamp, ip_address, country, referer, user_agent" + }; + + let mut sql = format!( + "SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2", + select_fields + ); + let mut params: Vec> = + vec![Box::new(target_type.to_string()), Box::new(target_id)]; + + if let Some(df) = clean_date_from.as_deref() { + sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1)); + params.push(Box::new(format!("{}T00:00:00Z", df))); + } + + if let Some(dt) = clean_date_to.as_deref() { + if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") { + let next_day = parsed_date + chrono::Duration::days(1); + sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1)); + params.push(Box::new(format!( + "{}T00:00:00Z", + next_day.format("%Y-%m-%d") + ))); + } + } + + sql.push_str(" ORDER BY timestamp DESC, id DESC"); + + let mut stmt = match conn.prepare(&sql) { + Ok(s) => s, + Err(_) => return, + }; + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) { + Ok(r) => r, + Err(_) => return, + }; + + let mut csv_buffer = String::new(); + + while let Ok(Some(row)) = rows.next() { + let timestamp: String = row.get(0).unwrap_or_default(); + let ip_address: String = row.get(1).unwrap_or_default(); + let country: String = row.get(2).unwrap_or_default(); + let referer: String = row.get(3).unwrap_or_default(); + let user_agent: String = row.get(4).unwrap_or_default(); + + let (browser, _, _) = parse_ua(&user_agent); + let referrer = clean_referrer(&referer); + let country_display = if country.is_empty() { + "Unknown".to_string() + } else { + country + }; + + let mut line = format!( + "{},{},{},{},{},{}", + escape_csv_field(×tamp), + escape_csv_field(&ip_address), + escape_csv_field(&country_display), + escape_csv_field(&referrer), + escape_csv_field(&browser), + escape_csv_field(&user_agent) + ); + + let mut col_idx = 5; + if has_utm_source { + let utm_src: String = row.get(col_idx).unwrap_or_default(); + line.push_str(&format!(",{}", escape_csv_field(&utm_src))); + col_idx += 1; + } + if has_utm_campaign { + let utm_camp: String = row.get(col_idx).unwrap_or_default(); + line.push_str(&format!(",{}", escape_csv_field(&utm_camp))); + } + line.push('\n'); + + csv_buffer.push_str(&line); + if csv_buffer.len() >= 8192 { + let bytes = axum::body::Bytes::from(csv_buffer); + if tx.blocking_send(Ok(bytes)).is_err() { + return; + } + csv_buffer = String::new(); + } + } + + if !csv_buffer.is_empty() { + let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer))); + } + }); + + let stream = DbExportStream { receiver: rx }; + let filename = if target_type == "url" { + format!("url_{}_analytics.csv", id) + } else { + format!("page_{}_analytics.csv", id) + }; + + ( + StatusCode::OK, + [ + ("Content-Type", "text/csv"), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), + ("X-BZOD-Export-Records", &count.to_string()), + ], + axum::body::Body::from_stream(stream), + ) + .into_response() +} +pub(crate) async fn perform_user_json_export( + user_dbs: crate::state::UserDbs, + target_type: &'static str, + id: String, + date_from: Option, + date_to: Option, +) -> Response { + let (clean_date_from, clean_date_to) = + match validate_date_filters(date_from.as_deref(), date_to.as_deref()) { + Ok(res) => res, + Err(status) => return status.into_response(), + }; + + let target_exists = { + let conn = user_dbs.content.lock().unwrap(); + if target_type == "url" { + get_url_by_id(&conn, &id) + .map(|u| u.is_some()) + .unwrap_or(false) + } else { + get_landing_page_by_id(&conn, &id) + .map(|p| p.is_some()) + .unwrap_or(false) + } + }; + if !target_exists { + return (StatusCode::NOT_FOUND, "Target not found").into_response(); + } + + let count = { + let conn = user_dbs.analytics.lock().unwrap(); + get_target_visit_total_filtered( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) + .unwrap_or(0) + }; + + if count > MAX_JSON_EXPORT_ROWS as i64 { + return StatusCode::PAYLOAD_TOO_LARGE.into_response(); + } + + let visits_raw = { + let conn = user_dbs.analytics.lock().unwrap(); + match get_target_visits_all_in_memory( + &conn, + target_type, + &id, + clean_date_from.as_deref(), + clean_date_to.as_deref(), + ) { + Ok(v) => v, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } + }; + + #[derive(serde::Serialize)] + struct JsonExportRow { + timestamp: String, + ip_address: String, + country: String, + referrer: String, + browser: String, + user_agent: String, + } + + let export_rows: Vec = visits_raw + .into_iter() + .map(|r| { + let (browser, _, _) = parse_ua(&r.user_agent); + let referrer = clean_referrer(&r.referer); + let country_display = if r.country.is_empty() { + "Unknown".to_string() + } else { + r.country + }; + JsonExportRow { + timestamp: r.timestamp, + ip_address: r.ip_address, + country: country_display, + referrer, + browser, + user_agent: r.user_agent, + } + }) + .collect(); + + let body_str = match serde_json::to_string(&export_rows) { + Ok(s) => s, + Err(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response() + } + }; + + let filename = if target_type == "url" { + format!("url_{}_analytics.json", id) + } else { + format!("page_{}_analytics.json", id) + }; + + ( + StatusCode::OK, + [ + ("Content-Type", "application/json"), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), + ("X-BZOD-Export-Records", &count.to_string()), + ], + body_str, + ) + .into_response() +} + +// --- Feature modules --- + +mod auth; +pub use auth::*; +mod dashboard; +pub use dashboard::*; +mod urls; +pub use urls::*; +mod pages; +pub use pages::*; +mod users; +pub use users::*; +mod analytics; +pub use analytics::*; +mod settings; +pub use settings::*; +mod audit; +pub use audit::*; +mod sessions; +pub use sessions::*; +mod quotas; +pub use quotas::*; +mod health; +pub use health::*; +mod backups; +pub use backups::*; +mod api_keys; +pub use api_keys::*; +mod moderation; +pub use moderation::*; diff --git a/src/web/admin/moderation.rs b/src/web/admin/moderation.rs new file mode 100644 index 0000000..ac3ed00 --- /dev/null +++ b/src/web/admin/moderation.rs @@ -0,0 +1,639 @@ +use super::*; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct GlobalSlugRow { + pub slug: String, + pub owner_user_id: i64, + pub target_type: String, + pub target_id: String, + pub created_at: String, + pub updated_at: String, + pub status: String, + pub deleted_at: Option, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ModerationLogEntry { + pub id: String, + pub timestamp: String, + pub admin_username: String, + pub target_user_id: i64, + pub target_username: Option, + pub resource_type: String, + pub resource_identifier: String, + pub action: String, + pub severity: String, + pub reason: String, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct SlugHistoryRow { + pub id: i64, + pub slug: String, + pub old_owner_user_id: Option, + pub new_owner_user_id: Option, + pub action: String, + pub timestamp: String, + pub admin_username: Option, +} + +// GET /admin/moderation +pub async fn moderation_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let flagged_items = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \ + FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;" + ).unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(GlobalSlugRow { + slug: row.get(0)?, + owner_user_id: row.get(1)?, + target_type: row.get(2)?, + target_id: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + status: row.get(6)?, + deleted_at: row.get(7)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let logs = { + let conn = state.system_db.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason \ + FROM moderation_events ORDER BY timestamp DESC LIMIT 50;" + ).unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(ModerationLogEntry { + id: row.get(0)?, + timestamp: row.get(1)?, + admin_username: row.get(2)?, + target_user_id: row.get(3)?, + target_username: row.get(4)?, + resource_type: row.get(5)?, + resource_identifier: row.get(6)?, + action: row.get(7)?, + severity: row.get(8)?, + reason: row.get(9)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::ModerationTemplate { + admin_username: user.username, + flagged_items, + logs, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct AdminModerateForm { + pub slug: String, + pub action: String, + pub severity: String, + pub reason: String, + pub csrf_token: String, +} + +// POST /admin/moderation +pub async fn moderation_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response(); + } + + let action = form.action.trim().to_lowercase(); + if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) { + return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response(); + } + + let (owner_user_id, target_type) = { + let system_conn = state.system_db.lock().unwrap(); + let row_opt: Option<(i64, String)> = system_conn + .query_row( + "SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;", + [&form.slug], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .unwrap_or(None); + + match row_opt { + Some(r) => r, + None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(), + } + }; + + let owner_username = { + let users_conn = state.users_db.lock().unwrap(); + crate::db::users::get_user_by_id(&users_conn, owner_user_id) + .unwrap_or(None) + .map(|u| u.username) + }; + + { + let system_conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + if action == "deleted" { + let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); + } else { + let _ = system_conn.execute( + "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![action, now, form.slug], + ); + } + + let event_id = Uuid::new_v4().to_string(); + let _ = system_conn.execute( + "INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", + rusqlite::params![ + event_id, + now, + user.username, + owner_user_id, + owner_username, + target_type, + form.slug, + action, + form.severity, + form.reason + ], + ); + + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "CONTENT_MODERATION", + "slug", + &form.slug, + Some(&format!("Action: {}, Reason: {}", action, form.reason)), + ); + } + + Redirect::to(&format!( + "/admin/moderation?success=Moderation action '{}' applied", + action + )) + .into_response() +} + +#[derive(Deserialize)] +pub struct SlugsQuery { + pub search: Option, + pub owner: Option, + pub status: Option, + pub success: Option, + pub error: Option, +} + +// GET /admin/slugs +pub async fn slugs_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let system_conn = state.system_db.lock().unwrap(); + + let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string(); + let mut values = vec![]; + if let Some(ref s) = query.search { + if !s.trim().is_empty() { + sql.push_str(" AND slug LIKE ?"); + values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim()))); + } + } + if let Some(o) = query.owner { + sql.push_str(" AND owner_user_id = ?"); + values.push(rusqlite::types::Value::Integer(o)); + } + if let Some(ref st) = query.status { + if !st.trim().is_empty() { + sql.push_str(" AND status = ?"); + values.push(rusqlite::types::Value::Text(st.trim().to_string())); + } + } + sql.push_str(" ORDER BY created_at DESC LIMIT 100;"); + + let slugs = { + let mut stmt = system_conn.prepare(&sql).unwrap(); + let rows = stmt + .query_map(rusqlite::params_from_iter(values.iter()), |row| { + Ok(GlobalSlugRow { + slug: row.get(0)?, + owner_user_id: row.get(1)?, + target_type: row.get(2)?, + target_id: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + status: row.get(6)?, + deleted_at: row.get(7)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let history = { + let mut stmt = system_conn.prepare( + "SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \ + FROM slug_history ORDER BY timestamp DESC LIMIT 50;" + ).unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(SlugHistoryRow { + id: row.get(0)?, + slug: row.get(1)?, + old_owner_user_id: row.get(2)?, + new_owner_user_id: row.get(3)?, + action: row.get(4)?, + timestamp: row.get(5)?, + admin_username: row.get(6)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::SlugsTemplate { + admin_username: user.username, + slugs, + history, + csrf_token, + search_filter: query.search, + owner_filter: query.owner, + status_filter: query.status, + success: query.success, + error: query.error, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct AdminTransferForm { + pub slug: String, + pub new_owner_user_id: i64, + pub csrf_token: String, +} + +// POST /admin/slugs/transfer +pub async fn slugs_transfer_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); + } + + let user_exists = { + let conn = state.users_db.lock().unwrap(); + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", + [form.new_owner_user_id], + |row| row.get::<_, bool>(0), + ) + .unwrap_or(false) + }; + + if !user_exists { + return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response(); + } + + let (old_owner, target_type, mut new_target_id) = + { + let conn = state.system_db.lock().unwrap(); + let row_opt: Option<(i64, String, String)> = conn.query_row( + "SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;", + [&form.slug], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + ).optional().unwrap_or(None); + match row_opt { + Some(r) => r, + None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(), + } + }; + + if old_owner == form.new_owner_user_id { + return Redirect::to("/admin/slugs?error=Slug is already owned by this user") + .into_response(); + } + + let old_dbs = match state.get_user_dbs(old_owner) { + Ok(dbs) => dbs, + Err(_) => { + return Redirect::to("/admin/slugs?error=Failed to load current owner's database") + .into_response() + } + }; + let new_dbs = match state.get_user_dbs(form.new_owner_user_id) { + Ok(dbs) => dbs, + Err(_) => { + return Redirect::to("/admin/slugs?error=Failed to load new owner's database") + .into_response() + } + }; + + { + let old_conn = old_dbs.content.lock().unwrap(); + let new_conn = new_dbs.content.lock().unwrap(); + + if target_type == "url" { + let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) { + Ok(u) => u, + Err(e) => { + return Redirect::to(&format!( + "/admin/slugs?error=Failed to retrieve old URL: {}", + e + )) + .into_response() + } + }; + + if let Some(url) = url_opt { + // Check quota + let users_conn = state.users_db.lock().unwrap(); + let quota_opt = + crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) + .unwrap_or(None); + if let Some(quota) = quota_opt { + if quota.current_urls >= quota.max_urls { + return Redirect::to( + "/admin/slugs?error=New owner has exceeded URL quota limit", + ) + .into_response(); + } + } + + // Copy + let new_url = match crate::db::content::create_url_extended( + &new_conn, + &url.code, + &url.destination, + url.title.as_deref(), + url.description.as_deref(), + &url.tags, + url.expires_at.as_deref(), + url.password_hash.as_deref(), + url.max_access_count, + ) { + Ok(u) => u, + Err(e) => { + return Redirect::to(&format!( + "/admin/slugs?error=Failed to copy URL record: {}", + e + )) + .into_response() + } + }; + new_target_id = new_url.id; + + // Delete old + let _ = crate::db::content::delete_url(&old_conn, &url.id); + } + } else if target_type == "page" { + let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug) + { + Ok(p) => p, + Err(e) => { + return Redirect::to(&format!( + "/admin/slugs?error=Failed to retrieve old page: {}", + e + )) + .into_response() + } + }; + + if let Some(page) = page_opt { + // Check quota + let users_conn = state.users_db.lock().unwrap(); + let quota_opt = + crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id) + .unwrap_or(None); + if let Some(quota) = quota_opt { + if quota.current_landings >= quota.max_landings { + return Redirect::to( + "/admin/slugs?error=New owner has exceeded landing page quota limit", + ) + .into_response(); + } + } + + // Copy + let new_page = match crate::db::content::create_landing_page( + &new_conn, + &page.code, + &page.slug, + &page.title, + &page.html_content, + &page.state, + ) { + Ok(p) => p, + Err(e) => { + return Redirect::to(&format!( + "/admin/slugs?error=Failed to copy page record: {}", + e + )) + .into_response() + } + }; + new_target_id = new_page.id; + + // Delete old + let _ = crate::db::content::delete_landing_page(&old_conn, &page.id); + } + } + } + + { + let conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let _ = conn.execute( + "UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;", + rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug], + ); + + let _ = conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \ + VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);", + rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username], + ); + + let _ = crate::db::audit_events::write_audit_event( + &conn, + &user.username, + "SLUG_TRANSFER", + "slug", + &form.slug, + Some(&format!( + "Transferred from {} to {}", + old_owner, form.new_owner_user_id + )), + ); + } + + Redirect::to(&format!( + "/admin/slugs?success=Slug /{} successfully transferred to user {}", + form.slug, form.new_owner_user_id + )) + .into_response() +} + +#[derive(Deserialize)] +pub struct AdminSlugStatusForm { + pub slug: String, + pub status: String, + pub csrf_token: String, +} + +// POST /admin/slugs/status +pub async fn slugs_status_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); + } + + let status = form.status.trim().to_lowercase(); + if !["active", "flagged", "disabled"].contains(&status.as_str()) { + return Redirect::to("/admin/slugs?error=Invalid status").into_response(); + } + + { + let conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let _ = conn.execute( + "UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![status, now, form.slug], + ); + + let _ = crate::db::audit_events::write_audit_event( + &conn, + &user.username, + "SLUG_STATUS_UPDATE", + "slug", + &form.slug, + Some(&format!("Status set to {}", status)), + ); + } + + Redirect::to(&format!( + "/admin/slugs?success=Slug /{} status updated to {}", + form.slug, status + )) + .into_response() +} + +#[derive(Deserialize)] +pub struct AdminSlugDeleteForm { + pub slug: String, + pub csrf_token: String, +} + +// POST /admin/slugs/delete +pub async fn slugs_delete_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response(); + } + + { + let conn = state.system_db.lock().unwrap(); + let now = Utc::now().to_rfc3339(); + + let old_owner_user_id: Option = conn + .query_row( + "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", + [&form.slug], + |row| row.get(0), + ) + .optional() + .unwrap_or(None); + + let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]); + + let _ = conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \ + VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);", + rusqlite::params![form.slug, old_owner_user_id, now, user.username], + ); + + let _ = crate::db::audit_events::write_audit_event( + &conn, + &user.username, + "SLUG_RELEASE", + "slug", + &form.slug, + Some("Slug released/deleted from global index"), + ); + } + + Redirect::to(&format!( + "/admin/slugs?success=Slug /{} released successfully", + form.slug + )) + .into_response() +} diff --git a/src/web/admin/pages.rs b/src/web/admin/pages.rs new file mode 100644 index 0000000..96a557d --- /dev/null +++ b/src/web/admin/pages.rs @@ -0,0 +1,484 @@ +use super::*; + +#[derive(Deserialize)] +pub struct UserPagesQuery { + pub error: Option, + pub page: Option, +} + +#[derive(Deserialize)] +pub struct CreateUserPageForm { + pub title: String, + pub slug: String, + pub code: String, + pub custom_slug: String, + pub state: String, + pub html_content: String, + pub csrf_token: String, +} + +pub async fn user_pages_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let (pages, total_pages, page, visible_pages) = { + let conn = user_dbs.content.lock().unwrap(); + let total_records = get_landing_page_count(&conn).unwrap_or(0); + let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * PAGE_SIZE; + + let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + (pages, total_pages, current_page, visible_pages) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UserPagesTemplate { + admin_username: user.username.clone(), + username: user.username, + pages, + csrf_token, + error: query.error, + current_page: page, + total_pages, + visible_pages, + }; + + template.into_response() +} + +pub async fn user_pages_create( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/user/pages?error=Invalid CSRF token").into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let mut code = form.custom_slug.trim().to_lowercase(); + if code.is_empty() { + code = form.code.trim().to_lowercase(); + if code.is_empty() { + code = generate_token(2); + } else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters") + .into_response(); + } + } else if !crate::utils::validation::validate_custom_slug(&code) { + return Redirect::to( + "/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _", + ) + .into_response(); + } + + let clean_slug = form.slug.trim().to_lowercase(); + if clean_slug.is_empty() { + return Redirect::to("/user/pages?error=Slug is required").into_response(); + } + + { + let users_conn = state.users_db.lock().unwrap(); + if !crate::db::users::check_quota_limit(&users_conn, user.id, "landings").unwrap_or(false) { + return Redirect::to("/user/pages?error=Quota limit exceeded").into_response(); + } + } + + { + let system_conn = state.system_db.lock().unwrap(); + if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { + return Redirect::to("/user/pages?error=Short code/slug already exists") + .into_response(); + } + if let Err(e) = crate::db::users::register_global_slug( + &system_conn, + &code, + user.id, + "page", + "", + "reserving", + ) { + return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e)) + .into_response(); + } + } + + let res = { + let conn = user_dbs.content.lock().unwrap(); + create_landing_page( + &conn, + &code, + &clean_slug, + &form.title, + &form.html_content, + &form.state, + ) + }; + + match res { + Ok(page) => { + { + let system_conn = state.system_db.lock().unwrap(); + let global_status = if form.state == "published" { + "active" + } else { + "disabled" + }; + let _ = system_conn.execute( + "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", + rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], + ); + } + { + let users_conn = state.users_db.lock().unwrap(); + let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "landings"); + } + let ip = get_client_ip(&headers, connect_info); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_PAGE_CREATION", + Some("page"), + Some(&page.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/pages").into_response() + } + Err(e) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); + Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response() + } + } +} + +pub async fn user_pages_delete( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/user/pages?error=Invalid CSRF token").into_response(); + } + + let conn = user_dbs.content.lock().unwrap(); + match get_landing_page_by_id(&conn, &id) { + Ok(Some(page)) => { + let _ = crate::db::users::decrement_quota_counter( + &state.users_db.lock().unwrap(), + user.id, + "landings", + ); + match delete_landing_page(&conn, &id) { + Ok(_) => { + let _ = crate::db::users::release_global_slug( + &state.system_db.lock().unwrap(), + &page.code, + user.id, + ); + let ip = get_client_ip(&headers, connect_info); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_PAGE_DELETION", + Some("page"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/pages").into_response() + } + Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e)) + .into_response(), + } + } + _ => Redirect::to("/user/pages?error=Page not found").into_response(), + } +} + +// GET /admin/pages +#[derive(Deserialize)] +pub struct PagesQuery { + pub error: Option, + pub page: Option, +} + +pub async fn pages_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let (pages, total_pages, page, visible_pages) = { + let conn = state.content_db.lock().unwrap(); + let total_records = get_landing_page_count(&conn).unwrap_or(0); + let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * PAGE_SIZE; + + let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default(); + + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + + (pages, total_pages, current_page, visible_pages) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::PagesTemplate { + admin_username: user.username, + pages, + csrf_token, + error: query.error, + current_page: page, + total_pages, + visible_pages, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct CreatePageForm { + pub title: String, + pub slug: String, + pub code: String, + pub custom_slug: String, + pub state: String, + pub html_content: String, + pub csrf_token: String, +} + +// POST /admin/pages/create +pub async fn pages_create( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + let admin_user_id = user.id.parse::().unwrap_or(1); + + // Custom Slug takes priority if provided + let mut code = form.custom_slug.trim().to_lowercase(); + if code.is_empty() { + code = form.code.trim().to_lowercase(); + if code.is_empty() { + code = generate_token(2); + } else { + if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Redirect::to( + "/admin/pages?error=Custom code must be exactly 4 hex characters", + ) + .into_response(); + } + } + } else { + if !crate::utils::validation::validate_custom_slug(&code) { + return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _") + .into_response(); + } + } + + let clean_slug = form.slug.trim().to_lowercase(); + if clean_slug.is_empty() { + return Redirect::to("/admin/pages?error=Slug is required").into_response(); + } + + { + let users_conn = state.users_db.lock().unwrap(); + if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings") + .unwrap_or(false) + { + return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response(); + } + } + + { + let system_conn = state.system_db.lock().unwrap(); + if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { + return Redirect::to("/admin/pages?error=Short code already exists").into_response(); + } + // Always use owner_user_id = 1 for admin content so it resolves via state.content_db + if let Err(e) = + crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving") + { + return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e)) + .into_response(); + } + } + + let res = { + let conn = state.content_db.lock().unwrap(); + create_landing_page( + &conn, + &code, + &clean_slug, + &form.title, + &form.html_content, + &form.state, + ) + }; + + match res { + Ok(page) => { + { + let system_conn = state.system_db.lock().unwrap(); + let global_status = if form.state == "published" { + "active" + } else { + "disabled" + }; + let _ = system_conn.execute( + "UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;", + rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code], + ); + } + { + let users_conn = state.users_db.lock().unwrap(); + let _ = crate::db::users::increment_quota_counter( + &users_conn, + admin_user_id, + "landings", + ); + } + { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "PAGE_CREATION", + Some("page"), + Some(&page.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + Redirect::to("/admin/pages").into_response() + } + Err(e) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); + Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response() + } + } +} + +// POST /admin/pages/delete/:id +pub async fn pages_delete( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.content_db.lock().unwrap(); + match delete_landing_page(&conn, &id) { + Ok(_) => { + { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "PAGE_DELETION", + Some("page"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + Redirect::to("/admin/pages").into_response() + } + Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e)) + .into_response(), + } +} diff --git a/src/web/admin/quotas.rs b/src/web/admin/quotas.rs new file mode 100644 index 0000000..955f45e --- /dev/null +++ b/src/web/admin/quotas.rs @@ -0,0 +1,117 @@ +use super::*; + +// GET /admin/quotas +pub async fn quotas_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let quotas = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas ORDER BY user_id ASC;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(crate::models::UserQuotas { + user_id: row.get(0)?, + max_urls: row.get(1)?, + max_landings: row.get(2)?, + max_api_tokens: row.get(3)?, + max_storage_mb: row.get(4)?, + current_urls: row.get(5)?, + current_landings: row.get(6)?, + current_api_tokens: row.get(7)?, + current_storage_mb: row.get(8)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::QuotasTemplate { + admin_username: user.username, + quotas, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// POST /admin/quotas +pub async fn quotas_post( + State(state): State, + jar: CookieJar, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response(); + } + + let action = form.get("action").cloned().unwrap_or_default(); + let users_conn = state.users_db.lock().unwrap(); + + if action == "reconcile_all" { + let user_ids: Vec = { + let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap(); + let rows = stmt.query_map([], |row| row.get(0)).unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + for uid in user_ids { + if let Ok(user_dbs) = state.get_user_dbs(uid) { + let user_content_conn = user_dbs.content.lock().unwrap(); + let _ = + crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn); + } + } + + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "QUOTAS_RECONCILE_ALL", + "quota", + "all", + None, + ); + + Redirect::to("/admin/quotas?success=All user quotas reconciled successfully") + .into_response() + } else if action == "reconcile" { + let uid_str = form.get("user_id").cloned().unwrap_or_default(); + let uid = uid_str.parse::().unwrap_or(0); + if let Ok(user_dbs) = state.get_user_dbs(uid) { + let user_content_conn = user_dbs.content.lock().unwrap(); + let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn); + + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.username, + "QUOTAS_RECONCILE", + "quota", + &uid.to_string(), + None, + ); + Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response() + } else { + Redirect::to("/admin/quotas?error=User databases not found").into_response() + } + } else { + Redirect::to("/admin/quotas?error=Invalid action").into_response() + } +} diff --git a/src/web/admin/sessions.rs b/src/web/admin/sessions.rs new file mode 100644 index 0000000..0452230 --- /dev/null +++ b/src/web/admin/sessions.rs @@ -0,0 +1,114 @@ +use super::*; + +// GET /admin/sessions +pub async fn sessions_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let sessions = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(crate::models::UserSession { + id: row.get(0)?, + user_id: row.get(1)?, + expires_at: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::SessionsTemplate { + admin_username: user.username, + sessions, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// POST /admin/sessions/revoke/:id +pub async fn sessions_revoke_post( + State(state): State, + jar: CookieJar, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response(); + } + + { + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]); + } + + { + let conn_sys = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &conn_sys, + &user.username, + "SESSION_REVOKED", + "session", + &id, + None, + ); + } + + Redirect::to("/admin/sessions?success=Session revoked").into_response() +} + +// POST /admin/sessions/revoke-all +pub async fn sessions_revoke_all_post( + State(state): State, + jar: CookieJar, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let form_csrf = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &form_csrf) { + return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response(); + } + + { + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute("DELETE FROM sessions;", []); + } + + { + let conn_sys = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &conn_sys, + &user.username, + "SESSIONS_ALL_REVOKED", + "session", + "all", + None, + ); + } + + Redirect::to("/admin/sessions?success=All active sessions revoked").into_response() +} diff --git a/src/web/admin/settings.rs b/src/web/admin/settings.rs new file mode 100644 index 0000000..899085b --- /dev/null +++ b/src/web/admin/settings.rs @@ -0,0 +1,1043 @@ +use super::*; + +#[derive(Deserialize)] +pub struct UserSettingsQuery { + pub success: Option, + pub error: Option, +} + +pub async fn user_settings_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = generate_csrf_token(&session_id); + let template = crate::templates::UserSettingsTemplate { + admin_username: user.username.clone(), + username: user.username, + csrf_token, + success: query.success, + error: query.error, + }; + + template.into_response() +} + +pub async fn user_change_password_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); + } + + if form.current_password.trim().is_empty() || form.new_password.trim().len() < 8 { + return Redirect::to("/user/settings?error=Password must be at least 8 characters") + .into_response(); + } + + let conn = state.users_db.lock().unwrap(); + if !verify_password(&form.current_password, &user.password_hash) { + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_PASSWORD_CHANGE_FAIL", + Some("user"), + Some(&user.id.to_string()), + Some(&get_client_ip(&headers, connect_info)), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + return Redirect::to("/user/settings?error=Incorrect current password").into_response(); + } + + let new_hash = match hash_password(&form.new_password) { + Ok(h) => h, + Err(_) => return Redirect::to("/user/settings?error=Hashing error").into_response(), + }; + + match conn.execute( + "UPDATE users SET password_hash = ?1 WHERE id = ?2;", + params![new_hash, user.id], + ) { + Ok(_) => { + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_PASSWORD_CHANGED", + Some("user"), + Some(&user.id.to_string()), + Some(&get_client_ip(&headers, connect_info)), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/settings?success=Password updated successfully").into_response() + } + Err(e) => Redirect::to(&format!( + "/user/settings?error=Failed to update password: {}", + e + )) + .into_response(), + } +} + +pub async fn user_download_backup( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, +) -> Response { + let (user, _) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + let user_dir = state + .config + .data_dir + .join("users") + .join(user.id.to_string()); + + let mut buffer = Vec::new(); + let res = { + let enc = GzEncoder::new(&mut buffer, Compression::default()); + let mut tar = Builder::new(enc); + let files = vec!["content.db", "analytics.db", "profile.db"]; + let mut add_err = None; + for f in files { + let path = user_dir.join(f); + if path.exists() { + if let Err(e) = tar.append_path_with_name(&path, f) { + add_err = Some(e); + break; + } + } + } + match add_err { + Some(e) => Err(e), + None => match tar.into_inner().and_then(|encoder| encoder.finish()) { + Ok(_) => Ok(()), + Err(e) => Err(e), + }, + } + }; + + match res { + Ok(_) => { + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_BACKUP", + Some("user"), + Some(&user.id.to_string()), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + let date_str = Utc::now().format("%Y-%m-%d").to_string(); + let filename = format!("user-{}-bzod-backup.tar.gz", date_str); + ( + StatusCode::OK, + [ + ("Content-Type", "application/gzip"), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), + ], + buffer, + ) + .into_response() + } + Err(e) => { + Redirect::to(&format!("/user/settings?error=Backup failed: {}", e)).into_response() + } + } +} + +pub async fn user_restore_backup_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + mut multipart: axum::extract::Multipart, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + let mut file_bytes = Vec::new(); + let mut confirm_text = String::new(); + let mut csrf_token = String::new(); + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name == "backup_file" { + if let Ok(bytes) = field.bytes().await { + file_bytes = bytes.to_vec(); + } + } else if name == "confirm_text" { + if let Ok(text) = field.text().await { + confirm_text = text.trim().to_string(); + } + } else if name == "csrf_token" { + if let Ok(token) = field.text().await { + csrf_token = token.trim().to_string(); + } + } + } + + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/user/settings?error=Invalid CSRF token").into_response(); + } + if confirm_text != "RESTORE" { + return Redirect::to("/user/settings?error=Confirmation text must be exactly 'RESTORE'") + .into_response(); + } + if file_bytes.is_empty() { + return Redirect::to("/user/settings?error=No backup file uploaded").into_response(); + } + + let temp_file_path = + std::env::temp_dir().join(format!("bzod_user_restore_{}.tar.gz", uuid::Uuid::new_v4())); + if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { + return Redirect::to(&format!( + "/user/settings?error=Failed to write temp file: {}", + e + )) + .into_response(); + } + + let user_dir = state + .config + .data_dir + .join("users") + .join(user.id.to_string()); + + let temp_unpack_dir = + std::env::temp_dir().join(format!("bzod_restore_unpack_{}", uuid::Uuid::new_v4())); + if let Err(e) = std::fs::create_dir_all(&temp_unpack_dir) { + let _ = std::fs::remove_file(&temp_file_path); + return Redirect::to(&format!( + "/user/settings?error=Failed to create temp dir: {}", + e + )) + .into_response(); + } + + let restore_res = { + let file = match File::open(&temp_file_path) { + Ok(f) => f, + Err(e) => { + let _ = std::fs::remove_file(&temp_file_path); + let _ = std::fs::remove_dir_all(&temp_unpack_dir); + return Redirect::to(&format!( + "/user/settings?error=Failed to open upload: {}", + e + )) + .into_response(); + } + }; + let tar_gz = GzDecoder::new(file); + let mut archive = tar::Archive::new(tar_gz); + if let Err(e) = archive.unpack(&temp_unpack_dir) { + Err(Box::new(e) as Box) + } else { + // Check for collision using temp content.db + let system_conn = state.system_db.lock().unwrap(); + let temp_content_db = temp_unpack_dir.join("content.db"); + if temp_content_db.exists() { + if let Err(e) = crate::db::users::register_restored_user_slugs( + &system_conn, + user.id, + &temp_content_db, + ) { + Err(e) + } else { + Ok(()) + } + } else { + Err("Backup is missing content.db".into()) + } + } + }; + + let _ = std::fs::remove_file(&temp_file_path); + + match restore_res { + Ok(_) => { + // Success! Copy unpacked files from temp_unpack_dir to user_dir + if let Err(e) = std::fs::create_dir_all(&user_dir) { + let _ = std::fs::remove_dir_all(&temp_unpack_dir); + return Redirect::to(&format!( + "/user/settings?error=Failed to create user directory: {}", + e + )) + .into_response(); + } + + for file_name in &["content.db", "analytics.db", "profile.db"] { + let src = temp_unpack_dir.join(file_name); + if src.exists() { + let dst = user_dir.join(file_name); + if let Err(e) = std::fs::copy(&src, &dst) { + let _ = std::fs::remove_dir_all(&temp_unpack_dir); + return Redirect::to(&format!( + "/user/settings?error=Failed to copy database: {}", + e + )) + .into_response(); + } + } + } + let _ = std::fs::remove_dir_all(&temp_unpack_dir); + + // Reconcile quotas + let users_conn = state.users_db.lock().unwrap(); + if let Ok(content_conn) = rusqlite::Connection::open(user_dir.join("content.db")) { + let _ = + crate::db::users::reconcile_user_quotas(&users_conn, user.id, &content_conn); + } + + let mut pool = state.user_dbs.lock().unwrap(); + pool.remove(&user.id); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_RESTORE", + Some("user"), + Some(&user.id.to_string()), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/settings?success=User database restored successfully") + .into_response() + } + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_unpack_dir); + Redirect::to(&format!("/user/settings?error=Restore failed: {}", e)).into_response() + } + } +} + +// GET /admin/settings +#[derive(Deserialize)] +pub struct SettingsQuery { + pub success: Option, + pub error: Option, +} + +pub async fn settings_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let api_keys = { + let conn = state.admin_db.lock().unwrap(); + list_api_keys(&conn, &user.id).unwrap_or_default() + }; + + let data_retention = { + let conn = state.admin_db.lock().unwrap(); + get_config(&conn, "retention_days") + .unwrap_or(None) + .unwrap_or_else(|| { + state + .config + .data_retention_days + .map(|d| d.to_string()) + .unwrap_or_else(|| "unlimited".to_string()) + }) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::SettingsTemplate { + admin_username: user.username, + api_keys, + data_retention, + csrf_token, + success: query.success, + error: query.error, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct ChangePasswordForm { + pub current_password: String, + pub new_password: String, + pub csrf_token: String, +} + +// POST /admin/settings/password +pub async fn change_password_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.admin_db.lock().unwrap(); + if !verify_password(&form.current_password, &user.password_hash) { + let _ = write_audit_log( + &conn, + &state, + &user.username, + "PASSWORD_CHANGE_FAIL", + Some("user"), + Some(&user.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + return Redirect::to("/admin/settings?error=Incorrect current password").into_response(); + } + + let new_hash = match hash_password(&form.new_password) { + Ok(h) => h, + Err(_) => return Redirect::to("/admin/settings?error=Hashing error").into_response(), + }; + + let res = conn.execute( + "UPDATE users SET password_hash = ?1 WHERE id = ?2;", + params![new_hash, user.id], + ); + match res { + Ok(_) => { + let _ = write_audit_log( + &conn, + &state, + &user.username, + "PASSWORD_CHANGE_SUCCESS", + Some("user"), + Some(&user.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/admin/settings?success=Password updated successfully").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/settings?error=Failed to update password: {}", + e + )) + .into_response(), + } +} + +#[derive(Deserialize)] +pub struct RetentionForm { + pub retention: String, + pub csrf_token: String, +} + +// POST /admin/settings/retention +pub async fn change_retention_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.admin_db.lock().unwrap(); + match set_config(&conn, "retention_days", &form.retention) { + Ok(_) => { + let _ = write_audit_log( + &conn, + &state, + &user.username, + "RETENTION_POLICY_CHANGED", + Some("config"), + Some("retention_days"), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/admin/settings?success=Retention policy saved").into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response() + } + } +} + +// POST /admin/settings/compact +pub async fn compact_db_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, +) -> Response { + let (user, _session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + + match state.db_compact() { + Ok(_) => { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn, + &state, + &user.username, + "DATABASE_COMPACTION", + Some("system"), + Some("all_dbs"), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/admin/settings?success=Database files compacted successfully") + .into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response() + } + } +} + +// GET /admin/settings/backup +pub async fn download_backup( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + + // Create tar.gz in memory + let mut buffer = Vec::new(); + let res = { + let enc = GzEncoder::new(&mut buffer, Compression::default()); + let mut tar = Builder::new(enc); + + let files = vec![ + ("admin.db", state.config.data_dir.join("admin/admin.db")), + ("system.db", state.config.data_dir.join("admin/system.db")), + ("users.db", state.config.data_dir.join("admin/users.db")), + ( + "content.db", + state.config.data_dir.join("users/1/content.db"), + ), + ( + "analytics.db", + state.config.data_dir.join("users/1/analytics.db"), + ), + ]; + + let mut add_err = None; + let mut manifest_files = Vec::new(); + + for (name, path) in files { + if path.exists() { + if let Err(e) = tar.append_path_with_name(&path, name) { + add_err = Some(e); + break; + } + manifest_files.push(name.to_string()); + } + } + + if add_err.is_none() { + let manifest = serde_json::json!({ + "created_at": chrono::Utc::now().to_rfc3339(), + "type": "legacy_flat_backup", + "files_included": manifest_files, + "note": "Multi-tenant databases flattened for backward compatibility.", + }); + let manifest_str = manifest.to_string(); + let mut header = tar::Header::new_gnu(); + header.set_size(manifest_str.len() as u64); + header.set_cksum(); + if let Err(e) = + tar.append_data(&mut header, "backup_manifest.json", manifest_str.as_bytes()) + { + add_err = Some(e); + } + } + + match add_err { + Some(e) => Err(e), + None => match tar.into_inner().and_then(|encoder| encoder.finish()) { + Ok(_) => Ok(()), + Err(e) => Err(e), + }, + } + }; + + match res { + Ok(_) => { + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn, + &state, + &user.username, + "DATABASE_BACKUP", + Some("system"), + Some("tarball"), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + + let date_str = Utc::now().format("%Y-%m-%d").to_string(); + let filename = format!("{}-bzod-backup.tar.gz", date_str); + + ( + StatusCode::OK, + [ + ("Content-Type", "application/gzip"), + ( + "Content-Disposition", + &format!("attachment; filename=\"{}\"", filename), + ), + ], + buffer, + ) + .into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response() + } + } +} + +#[derive(Deserialize)] +pub struct BulkQrExportForm { + pub format: String, + pub csrf_token: String, +} + +// POST /admin/settings/bulk-qr +pub async fn bulk_qr_export_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let urls = { + let conn = state.content_db.lock().unwrap(); + crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default() + }; + + if urls.is_empty() { + return Redirect::to("/admin/settings?error=No shortened URLs found to export") + .into_response(); + } + + let proto = if state.config.cookie_secure { + "https" + } else { + "http" + }; + let host_header = headers + .get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost:8654"); + let base_url = state + .config + .base_url + .clone() + .unwrap_or_else(|| format!("{}://{}", proto, host_header)); + + match crate::services::bulk::export_qr_zip(&urls, &form.format, &base_url) { + Ok(zip_data) => { + // Write Audit Log + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "BULK_QR_EXPORT", + Some("bulk"), + Some("qr"), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + + Response::builder() + .header("content-type", "application/zip") + .header( + "content-disposition", + "attachment; filename=\"qr_codes.zip\"", + ) + .body(axum::body::Body::from(zip_data)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } + Err(e) => { + Redirect::to(&format!("/admin/settings?error=Export failed: {}", e)).into_response() + } + } +} + +// GET /admin/status +pub async fn status_get(State(state): State, jar: CookieJar) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let app_status = "Healthy"; + + let db_status = { + let conn_ok = { + let conn = state.admin_db.lock().unwrap(); + get_user_count(&conn).is_ok() + }; + if conn_ok { + format!( + "Operational\n\nDatabase Files:\n{}", + get_db_file_info(&state.config.data_dir) + ) + } else { + "Degraded (Database connections failed)".to_string() + } + }; + + let queue_size = 0; + let memory_usage = get_memory_usage(); + + let uptime_duration = state.start_time.elapsed(); + let uptime = crate::utils::format_duration(uptime_duration); + + let urls = { + let conn = state.content_db.lock().unwrap(); + crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() + }; + + let template = crate::templates::StatusTemplate { + admin_username: user.username, + app_status, + db_status, + queue_size, + memory_usage, + uptime, + version: "0.1.0", + git_commit: "unknown", + urls, + }; + + template.into_response() +} + +// GET /user/status +pub async fn user_status_get(State(state): State, jar: CookieJar) -> Response { + let (user, _) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let app_status = "Healthy"; + + let db_status = { + let conn_ok = { + let conn = user_dbs.content.lock().unwrap(); + get_url_counts(&conn).is_ok() + }; + if conn_ok { + "Operational".to_string() + } else { + "Degraded (Database connection failed)".to_string() + } + }; + + let queue_size = 0; + let memory_usage = get_memory_usage(); + + let uptime_duration = state.start_time.elapsed(); + let uptime = crate::utils::format_duration(uptime_duration); + + let urls = { + let conn = user_dbs.content.lock().unwrap(); + crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default() + }; + + let template = crate::templates::UserStatusTemplate { + admin_username: user.username, + app_status, + db_status, + queue_size, + memory_usage, + uptime, + version: "0.1.0", + git_commit: "unknown", + urls, + }; + + template.into_response() +} + +// POST /admin/settings/restore +pub async fn restore_backup_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + mut multipart: axum::extract::Multipart, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + let mut file_bytes = Vec::new(); + let mut confirm_text = String::new(); + let mut csrf_token = String::new(); + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name == "backup_file" { + if let Ok(bytes) = field.bytes().await { + file_bytes = bytes.to_vec(); + } + } else if name == "confirm_text" { + if let Ok(text) = field.text().await { + confirm_text = text.trim().to_string(); + } + } else if name == "csrf_token" { + if let Ok(token) = field.text().await { + csrf_token = token.trim().to_string(); + } + } + } + + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + if confirm_text != "RESTORE" { + return Redirect::to("/admin/settings?error=Confirmation text must be exactly 'RESTORE'") + .into_response(); + } + + if file_bytes.is_empty() { + return Redirect::to("/admin/settings?error=No backup file uploaded").into_response(); + } + + // Save uploaded archive to a temporary file + let temp_file_path = + std::env::temp_dir().join(format!("bzod_restore_{}.tar.gz", uuid::Uuid::new_v4())); + if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) { + return Redirect::to(&format!( + "/admin/settings?error=Failed to write temp file: {}", + e + )) + .into_response(); + } + + // Log RESTORE_INITIATED audit event before restore + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn, + &state, + &user.username, + "RESTORE_INITIATED", + Some("system"), + Some("tarball"), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + + // Call the perform_restore engine inside closed connection blocks + let restore_res = { + // Temporarily suspend access to active SQLite connections + let mut admin_conn = state.admin_db.lock().unwrap(); + let mut content_conn = state.content_db.lock().unwrap(); + let mut analytics_conn = state.analytics_db.lock().unwrap(); + let mut system_conn = state.system_db.lock().unwrap(); + + // 1. Close current connections by replacing them with dummy in-memory DBs + *admin_conn = match rusqlite::Connection::open_in_memory() { + Ok(c) => c, + Err(e) => { + return Redirect::to(&format!( + "/admin/settings?error=Failed to open temp in-memory DB: {}", + e + )) + .into_response() + } + }; + *content_conn = match rusqlite::Connection::open_in_memory() { + Ok(c) => c, + Err(e) => { + return Redirect::to(&format!( + "/admin/settings?error=Failed to open temp in-memory DB: {}", + e + )) + .into_response() + } + }; + *analytics_conn = match rusqlite::Connection::open_in_memory() { + Ok(c) => c, + Err(e) => { + return Redirect::to(&format!( + "/admin/settings?error=Failed to open temp in-memory DB: {}", + e + )) + .into_response() + } + }; + *system_conn = match rusqlite::Connection::open_in_memory() { + Ok(c) => c, + Err(e) => { + return Redirect::to(&format!( + "/admin/settings?error=Failed to open temp in-memory DB: {}", + e + )) + .into_response() + } + }; + + // 2. Perform restore unpacking/validation + // perform_restore() handles legacy layout normalization internally + // before validation, so no post-restore normalization is needed. + let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir); + + // 3. Reinitialize database connections using correct multi-tenant paths + let paths = + crate::services::backup_layout::RestoredDbPaths::from_data_dir(&state.config.data_dir); + let new_admin = rusqlite::Connection::open(&paths.admin); + let new_system = rusqlite::Connection::open(&paths.system); + let new_users = rusqlite::Connection::open(&paths.users); + let new_content = rusqlite::Connection::open(&paths.content); + let new_analytics = rusqlite::Connection::open(&paths.analytics); + + match (new_admin, new_content, new_analytics, new_system, new_users) { + (Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => { + let _ = crate::db::sqlite::enable_wal(&adm, "admin"); + let _ = crate::db::sqlite::enable_wal(&cnt, "content"); + let _ = crate::db::sqlite::enable_wal(&any, "analytics"); + let _ = crate::db::sqlite::enable_wal(&sys, "system"); + let _ = crate::db::sqlite::enable_wal(&usr, "users"); + + let _ = crate::db::sqlite::enable_foreign_keys(&adm, "admin"); + let _ = crate::db::sqlite::enable_foreign_keys(&cnt, "content"); + let _ = crate::db::sqlite::enable_foreign_keys(&any, "analytics"); + let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system"); + let _ = crate::db::sqlite::enable_foreign_keys(&usr, "users"); + + *admin_conn = adm; + *content_conn = cnt; + *analytics_conn = any; + *system_conn = sys; + match state.db.users.lock() { + Ok(mut u) => *u = usr, + Err(_) => { + return Redirect::to( + "/admin/settings?error=Failed to reopen restored databases", + ) + .into_response(); + } + } + } + _ => { + return Redirect::to("/admin/settings?error=Failed to reopen restored databases") + .into_response(); + } + } + + res + }; + + let _ = std::fs::remove_file(&temp_file_path); + + match restore_res { + Ok(_) => { + // Write database restore success log to newly restored admin db + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn, + &state, + &user.username, + "DATABASE_RESTORE", + Some("system"), + Some("tarball"), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + + // Run doctor check to verify integrity after restore (spawn in background since we can't easily await here) + tracing::info!("Running post-restore diagnostics..."); + let config_clone = state.config.clone(); + tokio::spawn(async move { + let _ = crate::cli::doctor::run(None, config_clone).await; + }); + + Redirect::to("/admin/login").into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/settings?error=Restore failed: {}", e)).into_response() + } + } +} diff --git a/src/web/admin/urls.rs b/src/web/admin/urls.rs new file mode 100644 index 0000000..abaaa5d --- /dev/null +++ b/src/web/admin/urls.rs @@ -0,0 +1,639 @@ +use super::*; + +#[derive(Deserialize)] +pub struct UserUrlsQuery { + pub tag: Option, + pub error: Option, + pub page: Option, +} + +#[derive(Deserialize)] +pub struct CreateUserUrlForm { + pub destination: String, + #[serde(default)] + pub code: String, + #[serde(default)] + pub custom_slug: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub tags: String, + pub csrf_token: String, + #[serde(default)] + pub expires_at: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub max_access_count: String, + #[serde(default)] + pub utm_source: String, + #[serde(default)] + pub utm_medium: String, + #[serde(default)] + pub utm_campaign: String, +} + +pub async fn user_urls_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let (urls, total_pages, page, visible_pages) = { + let conn = user_dbs.content.lock().unwrap(); + let total_records = if let Some(tag_str) = query.tag.as_deref() { + get_url_count_by_tag(&conn, tag_str).unwrap_or(0) + } else { + get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) + }; + let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * PAGE_SIZE; + + let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) + .unwrap_or_default(); + + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + + (urls, total_pages, current_page, visible_pages) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let proto = if state.config.cookie_secure { + "https" + } else { + "http" + }; + let base_url = state + .config + .base_url + .clone() + .unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port)); + + let template = crate::templates::UserUrlsTemplate { + admin_username: user.username.clone(), + username: user.username, + urls, + csrf_token, + error: query.error, + tag_filter: query.tag, + base_url, + current_page: page, + total_pages, + visible_pages, + }; + + template.into_response() +} + +pub async fn user_urls_create( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/user/urls?error=Invalid CSRF token").into_response(); + } + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + + let mut code = form.custom_slug.trim().to_lowercase(); + if code.is_empty() { + code = form.code.trim().to_lowercase(); + if code.is_empty() { + code = generate_token(3); + } else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters") + .into_response(); + } + } else if !crate::utils::validation::validate_custom_slug(&code) { + return Redirect::to( + "/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _", + ) + .into_response(); + } + + { + let users_conn = state.users_db.lock().unwrap(); + if !crate::db::users::check_quota_limit(&users_conn, user.id, "urls").unwrap_or(false) { + return Redirect::to("/user/urls?error=Quota limit exceeded").into_response(); + } + } + + { + let system_conn = state.system_db.lock().unwrap(); + if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { + return Redirect::to("/user/urls?error=Short code/slug already exists").into_response(); + } + if let Err(e) = crate::db::users::register_global_slug( + &system_conn, + &code, + user.id, + "url", + "", + "reserving", + ) { + return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e)) + .into_response(); + } + } + + let dest = match crate::services::urls::prepare_destination( + &form.destination, + crate::services::urls::UtmParams { + source: Some(&form.utm_source), + medium: Some(&form.utm_medium), + campaign: Some(&form.utm_campaign), + }, + ) { + Ok(d) => d, + Err(msg) => { + return Redirect::to(&format!("/user/urls?error={}", msg)).into_response(); + } + }; + + let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at); + + let password_hash_opt = if form.password.trim().is_empty() { + None + } else { + match hash_password(&form.password) { + Ok(h) => Some(h), + Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(), + } + }; + + let max_access_count_opt = if form.max_access_count.trim().is_empty() { + None + } else { + match form.max_access_count.trim().parse::() { + Ok(c) => Some(c), + Err(_) => { + return Redirect::to("/user/urls?error=Invalid max access count").into_response() + } + } + }; + + let tags_list: Vec = form + .tags + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let title_opt = if form.title.trim().is_empty() { + None + } else { + Some(form.title.trim()) + }; + let desc_opt = if form.description.trim().is_empty() { + None + } else { + Some(form.description.trim()) + }; + + let res = { + let conn = user_dbs.content.lock().unwrap(); + crate::db::content::create_url_extended( + &conn, + &code, + &dest, + title_opt, + desc_opt, + &tags_list, + expires_at_opt.as_deref(), + password_hash_opt.as_deref(), + max_access_count_opt, + ) + }; + + match res { + Ok(url) => { + { + let system_conn = state.system_db.lock().unwrap(); + let _ = system_conn.execute( + "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], + ); + } + { + let users_conn = state.users_db.lock().unwrap(); + let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "urls"); + } + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_URL_CREATION", + Some("url"), + Some(&url.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/urls").into_response() + } + Err(e) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id); + Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response() + } + } +} + +pub async fn user_urls_delete( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_user_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let user_dbs = match state.get_user_dbs(user.id) { + Ok(dbs) => dbs, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/user/urls?error=Invalid CSRF token").into_response(); + } + + let conn = user_dbs.content.lock().unwrap(); + match get_url_by_id(&conn, &id) { + Ok(Some(url)) => { + let _ = crate::db::users::decrement_quota_counter( + &state.users_db.lock().unwrap(), + user.id, + "urls", + ); + match delete_url(&conn, &id) { + Ok(_) => { + let _ = crate::db::users::release_global_slug( + &state.system_db.lock().unwrap(), + &url.code, + user.id, + ); + let ip = get_client_ip(&headers, connect_info); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &state, + &user.username, + "USER_URL_DELETION", + Some("url"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/user/urls").into_response() + } + Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e)) + .into_response(), + } + } + _ => Redirect::to("/user/urls?error=Link not found").into_response(), + } +} + +// GET /admin/urls +#[derive(Deserialize)] +pub struct UrlsQuery { + pub tag: Option, + pub error: Option, + pub page: Option, +} + +pub async fn urls_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let (urls, total_pages, page, visible_pages) = { + let conn = state.content_db.lock().unwrap(); + let total_records = if let Some(tag_str) = query.tag.as_deref() { + get_url_count_by_tag(&conn, tag_str).unwrap_or(0) + } else { + get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0) + }; + let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE); + let total_pages = std::cmp::max(1, calculated_total_pages); + let requested_page = query.page.unwrap_or(1); + let current_page = if requested_page == 0 { + 1 + } else { + requested_page + } + .clamp(1, total_pages); + let offset = (current_page - 1) * PAGE_SIZE; + + let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref()) + .unwrap_or_default(); + + let start_page = current_page.saturating_sub(3).max(1); + let end_page = std::cmp::min(total_pages, current_page + 3); + let visible_pages: Vec = (start_page..=end_page).collect(); + + (urls, total_pages, current_page, visible_pages) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let proto = if state.config.cookie_secure { + "https" + } else { + "http" + }; + let base_url = state + .config + .base_url + .clone() + .unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port)); + + let template = crate::templates::UrlsTemplate { + admin_username: user.username, + urls, + csrf_token, + error: query.error, + tag_filter: query.tag, + base_url, + current_page: page, + total_pages, + visible_pages, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct CreateUrlForm { + pub destination: String, + pub code: String, + pub custom_slug: String, + pub title: String, + pub description: String, + pub tags: String, + pub csrf_token: String, + pub expires_at: String, + pub password: String, + pub max_access_count: String, + pub utm_source: String, + pub utm_medium: String, + pub utm_campaign: String, +} + +// POST /admin/urls/create +pub async fn urls_create( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + let admin_user_id = user.id.parse::().unwrap_or(1); + + // Custom Slug takes priority if provided + let mut code = form.custom_slug.trim().to_lowercase(); + if code.is_empty() { + code = form.code.trim().to_lowercase(); + if code.is_empty() { + code = generate_token(3); + } else { + if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Redirect::to( + "/admin/urls?error=Custom code must be exactly 6 hex characters", + ) + .into_response(); + } + } + } else { + if !crate::utils::validation::validate_custom_slug(&code) { + return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _") + .into_response(); + } + } + + let dest = match crate::services::urls::prepare_destination( + &form.destination, + crate::services::urls::UtmParams { + source: Some(&form.utm_source), + medium: Some(&form.utm_medium), + campaign: Some(&form.utm_campaign), + }, + ) { + Ok(d) => d, + Err(msg) => { + return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response(); + } + }; + + let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at); + + let password_hash_opt = if form.password.trim().is_empty() { + None + } else { + match hash_password(&form.password) { + Ok(h) => Some(h), + Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(), + } + }; + + let max_access_count_opt = if form.max_access_count.trim().is_empty() { + None + } else { + match form.max_access_count.trim().parse::() { + Ok(c) => Some(c), + Err(_) => { + return Redirect::to("/admin/urls?error=Invalid max access count").into_response() + } + } + }; + + let tags_list: Vec = form + .tags + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let title_opt = if form.title.trim().is_empty() { + None + } else { + Some(form.title.trim()) + }; + let desc_opt = if form.description.trim().is_empty() { + None + } else { + Some(form.description.trim()) + }; + + { + let users_conn = state.users_db.lock().unwrap(); + if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false) + { + return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response(); + } + } + + { + let system_conn = state.system_db.lock().unwrap(); + if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { + return Redirect::to("/admin/urls?error=Short code/slug already exists") + .into_response(); + } + // Always use owner_user_id = 1 for admin content so it resolves via state.content_db + if let Err(e) = + crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving") + { + return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e)) + .into_response(); + } + } + + let res = { + let conn = state.content_db.lock().unwrap(); + crate::db::content::create_url_extended( + &conn, + &code, + &dest, + title_opt, + desc_opt, + &tags_list, + expires_at_opt.as_deref(), + password_hash_opt.as_deref(), + max_access_count_opt, + ) + }; + + match res { + Ok(url) => { + { + let system_conn = state.system_db.lock().unwrap(); + let _ = system_conn.execute( + "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", + rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code], + ); + } + { + let users_conn = state.users_db.lock().unwrap(); + let _ = + crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls"); + } + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn, + &state, + &user.username, + "URL_CREATION", + Some("url"), + Some(&url.id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + Redirect::to("/admin/urls").into_response() + } + Err(e) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); + Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response() + } + } +} + +// POST /admin/urls/delete/:id +pub async fn urls_delete( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.content_db.lock().unwrap(); + match delete_url(&conn, &id) { + Ok(_) => { + { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "URL_DELETION", + Some("url"), + Some(&id), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + } + Redirect::to("/admin/urls").into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response() + } + } +} diff --git a/src/web/admin/users.rs b/src/web/admin/users.rs new file mode 100644 index 0000000..9015cd9 --- /dev/null +++ b/src/web/admin/users.rs @@ -0,0 +1,704 @@ +use super::*; + +#[derive(Deserialize)] +pub struct UsersQuery { + pub success: Option, + pub error: Option, +} + +#[derive(Deserialize)] +pub struct CreateUserForm { + pub username: String, + pub password: String, + pub account_type: String, + pub metadata: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct UpdateUserStatusForm { + pub status: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct UpdateUserTypeForm { + pub account_type: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct ResetPasswordForm { + pub new_password: String, + pub csrf_token: String, +} + +#[derive(Deserialize)] +pub struct DeleteUserForm { + pub csrf_token: String, +} + +pub async fn users_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let users = { + let conn = state.users_db.lock().unwrap(); + crate::db::users::list_users(&conn).unwrap_or_default() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UsersTemplate { + admin_username: user.username, + users, + csrf_token, + success: query.success, + error: query.error, + }; + + template.into_response() +} + +pub async fn users_create_post( + State(state): State, + jar: CookieJar, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + let username = form.username.trim().to_lowercase(); + if username.len() < 3 { + return Redirect::to("/admin/users?error=Username must be at least 3 characters") + .into_response(); + } + if !username + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Redirect::to( + "/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores", + ) + .into_response(); + } + + if form.password.trim().len() < 8 { + return Redirect::to("/admin/users?error=Password must be at least 8 characters") + .into_response(); + } + + let account_type = if form.account_type.trim().is_empty() { + "standard" + } else { + form.account_type.trim() + }; + + let metadata = if form.metadata.trim().is_empty() { + None + } else { + Some(form.metadata.trim()) + }; + + let hash = match hash_password(&form.password) { + Ok(h) => h, + Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(), + }; + + let new_user = { + let conn = state.users_db.lock().unwrap(); + match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) { + Ok(u) => u, + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + return Redirect::to("/admin/users?error=Username already exists").into_response(); + } + Err(e) => { + return Redirect::to(&format!("/admin/users?error=Database error: {}", e)) + .into_response(); + } + } + }; + + if let Err(e) = state.db.init_user_databases(new_user.id) { + return Redirect::to(&format!( + "/admin/users?error=Failed to initialize user databases: {}", + e + )) + .into_response(); + } + + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn, + &state, + &user.username, + "USER_CREATION", + Some("user"), + Some(&new_user.id.to_string()), + None, + None, + ); + } + + Redirect::to("/admin/users?success=User created successfully").into_response() +} + +pub async fn users_update_status_post( + State(state): State, + jar: CookieJar, + Path(id): Path, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + let status = form.status.trim().to_lowercase(); + if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) { + return Redirect::to("/admin/users?error=Invalid user status").into_response(); + } + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::update_user_status(&conn, id, &status) { + Ok(_) => { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "USER_STATUS_UPDATE", + Some("user"), + Some(&id.to_string()), + None, + None, + ); + Redirect::to("/admin/users?success=User status updated").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/users?error=Failed to update status: {}", + e + )) + .into_response(), + } +} + +pub async fn users_update_type_post( + State(state): State, + jar: CookieJar, + Path(id): Path, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + let account_type = form.account_type.trim().to_lowercase(); + if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str()) + { + return Redirect::to("/admin/users?error=Invalid account type").into_response(); + } + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::update_user_account_type(&conn, id, &account_type) { + Ok(_) => { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "USER_ACCOUNT_TYPE_UPDATE", + Some("user"), + Some(&id.to_string()), + None, + None, + ); + Redirect::to("/admin/users?success=User account type updated").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/users?error=Failed to update account type: {}", + e + )) + .into_response(), + } +} + +pub async fn users_reset_password_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + if form.new_password.trim().len() < 8 { + return Redirect::to("/admin/users?error=Password must be at least 8 characters") + .into_response(); + } + + let hash = match hash_password(&form.new_password) { + Ok(h) => h, + Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(), + }; + + let conn = state.users_db.lock().unwrap(); + match crate::db::users::reset_user_password(&conn, id, &hash) { + Ok(_) => { + let ip = get_client_ip(&headers, connect_info); + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "USER_PASSWORD_RESET", + Some("user"), + Some(&id.to_string()), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/admin/users?success=Password reset successfully").into_response() + } + Err(e) => Redirect::to(&format!( + "/admin/users?error=Failed to reset password: {}", + e + )) + .into_response(), + } +} + +pub async fn users_delete_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/users?error=Invalid CSRF token").into_response(); + } + + match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) { + Ok(_) => { + let ip = get_client_ip(&headers, connect_info); + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log( + &conn_admin, + &state, + &user.username, + "USER_DELETION", + Some("user"), + Some(&id.to_string()), + Some(&ip), + headers.get("user-agent").and_then(|h| h.to_str().ok()), + ); + Redirect::to("/admin/users?success=User deleted successfully").into_response() + } + Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(), + } +} + +// --------------------------------------------------------------------------- +// GA Hardening UI Handlers and Structs +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct UserDetailStats { + pub max_urls: i64, + pub max_landings: i64, + pub max_api_tokens: i64, + pub max_storage_mb: i64, + pub current_urls: i64, + pub current_landings: i64, + pub current_api_tokens: i64, + pub current_storage_mb: i64, + pub url_pct: i64, + pub landing_pct: i64, + pub token_pct: i64, + pub storage_pct: i64, + pub total_visits: i64, +} + +pub fn get_user_detail_stats( + state: &AppState, + user_id: i64, +) -> Result> { + use rusqlite::OptionalExtension; + let quotas = { + let conn = state.users_db.lock().unwrap(); + conn.query_row( + "SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \ + FROM quotas WHERE user_id = ?1;", + [user_id], + |row| Ok(crate::models::UserQuotas { + user_id, + max_urls: row.get(0)?, + max_landings: row.get(1)?, + max_api_tokens: row.get(2)?, + max_storage_mb: row.get(3)?, + current_urls: row.get(4)?, + current_landings: row.get(5)?, + current_api_tokens: row.get(6)?, + current_storage_mb: row.get(7)?, + }) + ).optional()? + }; + + let quotas = quotas.unwrap_or(crate::models::UserQuotas { + user_id, + max_urls: 100, + max_landings: 10, + max_api_tokens: 5, + max_storage_mb: 100, + current_urls: 0, + current_landings: 0, + current_api_tokens: 0, + current_storage_mb: 0, + }); + + let total_visits = { + if let Ok(dbs) = state.get_user_dbs(user_id) { + let conn = dbs.analytics.lock().unwrap(); + conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap_or(0) + } else { + 0 + } + }; + + let url_pct = if quotas.max_urls > 0 { + (quotas.current_urls * 100) / quotas.max_urls + } else { + 0 + }; + let landing_pct = if quotas.max_landings > 0 { + (quotas.current_landings * 100) / quotas.max_landings + } else { + 0 + }; + let token_pct = if quotas.max_api_tokens > 0 { + (quotas.current_api_tokens * 100) / quotas.max_api_tokens + } else { + 0 + }; + let storage_pct = if quotas.max_storage_mb > 0 { + (quotas.current_storage_mb * 100) / quotas.max_storage_mb + } else { + 0 + }; + + Ok(UserDetailStats { + max_urls: quotas.max_urls, + max_landings: quotas.max_landings, + max_api_tokens: quotas.max_api_tokens, + max_storage_mb: quotas.max_storage_mb, + current_urls: quotas.current_urls, + current_landings: quotas.current_landings, + current_api_tokens: quotas.current_api_tokens, + current_storage_mb: quotas.current_storage_mb, + url_pct, + landing_pct, + token_pct, + storage_pct, + total_visits, + }) +} + +// GET /admin/users/new +pub async fn users_new_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UsersNewTemplate { + admin_username: user.username, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// GET /admin/users/:id +pub async fn user_detail_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let target_user = { + let conn = state.users_db.lock().unwrap(); + let u_res = conn.query_row( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ + FROM users WHERE id = ?1;", + [id], + |row| Ok(crate::models::TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + ); + match u_res { + Ok(u) => u, + Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), + } + }; + + let stats = match get_user_detail_stats(&state, id) { + Ok(s) => s, + Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(), + }; + + let sessions = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;") + .unwrap(); + let rows = stmt + .query_map([id], |row| { + Ok(crate::models::UserSession { + id: row.get(0)?, + user_id: row.get(1)?, + expires_at: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let tokens = { + let conn = state.users_db.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;", + ) + .unwrap(); + let rows = stmt + .query_map([id], |row| { + Ok(crate::models::UserApiToken { + id: row.get(0)?, + user_id: row.get(1)?, + token_hash: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .unwrap(); + rows.filter_map(|r| r.ok()).collect() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UserDetailTemplate { + admin_username: user.username, + target_user, + stats, + sessions, + tokens, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +// GET /admin/users/:id/edit +pub async fn user_edit_get( + State(state): State, + jar: CookieJar, + Path(id): Path, + Query(params): Query>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let target_user = { + let conn = state.users_db.lock().unwrap(); + let u_res = conn.query_row( + "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \ + FROM users WHERE id = ?1;", + [id], + |row| Ok(crate::models::TenantUser { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + last_login: row.get(5)?, + account_type: row.get(6)?, + organization_id: row.get(7)?, + metadata: row.get(8)?, + }) + ); + match u_res { + Ok(u) => u, + Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(), + } + }; + + let quotas = { + let conn = state.users_db.lock().unwrap(); + conn.query_row( + "SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \ + FROM quotas WHERE user_id = ?1;", + [id], + |row| Ok(crate::models::UserQuotas { + user_id: id, + max_urls: row.get(0)?, + max_landings: row.get(1)?, + max_api_tokens: row.get(2)?, + max_storage_mb: row.get(3)?, + current_urls: row.get(4)?, + current_landings: row.get(5)?, + current_api_tokens: row.get(6)?, + current_storage_mb: row.get(7)?, + }) + ).unwrap_or(crate::models::UserQuotas { + user_id: id, + max_urls: 100, + max_landings: 10, + max_api_tokens: 5, + max_storage_mb: 100, + current_urls: 0, + current_landings: 0, + current_api_tokens: 0, + current_storage_mb: 0, + }) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UserEditTemplate { + admin_username: user.username, + target_user, + quotas, + csrf_token, + success: params.get("success").cloned(), + error: params.get("error").cloned(), + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct UserEditForm { + pub account_type: String, + pub metadata: String, + pub max_urls: i64, + pub max_landings: i64, + pub max_api_tokens: i64, + pub max_storage_mb: i64, + pub csrf_token: String, +} + +// POST /admin/users/:id/edit +pub async fn user_edit_post( + State(state): State, + jar: CookieJar, + Path(id): Path, + Form(form): Form, +) -> Response { + let (_user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to(&format!( + "/admin/users/{}/edit?error=Invalid CSRF token", + id + )) + .into_response(); + } + + let conn = state.users_db.lock().unwrap(); + let _ = conn.execute( + "UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;", + rusqlite::params![form.account_type, form.metadata, id], + ); + + let _ = conn.execute( + "INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) \ + VALUES (?1, ?2, ?3, ?4, ?5) \ + ON CONFLICT(user_id) DO UPDATE SET \ + max_urls = excluded.max_urls, \ + max_landings = excluded.max_landings, \ + max_api_tokens = excluded.max_api_tokens, \ + max_storage_mb = excluded.max_storage_mb;", + rusqlite::params![ + id, + form.max_urls, + form.max_landings, + form.max_api_tokens, + form.max_storage_mb + ], + ); + + Redirect::to(&format!( + "/admin/users/{}?success=User updated successfully", + id + )) + .into_response() +} diff --git a/src/web/api.rs b/src/web/api.rs index 48901fe..3f45e49 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -99,34 +99,23 @@ pub async fn api_create_url( } } - let mut dest = payload.destination.trim().to_string(); - if let Ok(mut parsed) = reqwest::Url::parse(&dest) { - let mut has_utm = false; - { - let mut query = parsed.query_pairs_mut(); - if let Some(ref src) = payload.utm_source { - if !src.trim().is_empty() { - query.append_pair("utm_source", src.trim()); - has_utm = true; - } - } - if let Some(ref med) = payload.utm_medium { - if !med.trim().is_empty() { - query.append_pair("utm_medium", med.trim()); - has_utm = true; - } - } - if let Some(ref camp) = payload.utm_campaign { - if !camp.trim().is_empty() { - query.append_pair("utm_campaign", camp.trim()); - has_utm = true; - } - } + let dest = match crate::services::urls::prepare_destination( + &payload.destination, + crate::services::urls::UtmParams { + source: payload.utm_source.as_deref(), + medium: payload.utm_medium.as_deref(), + campaign: payload.utm_campaign.as_deref(), + }, + ) { + Ok(d) => d, + Err(msg) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": msg })), + ) + .into_response(); } - if has_utm { - dest = parsed.to_string(); - } - } + }; let password_hash = if let Some(ref pwd) = payload.password { if pwd.is_empty() { @@ -353,6 +342,15 @@ pub async fn api_update_url( Json(payload): Json, ) -> Response { let tags = payload.tags.unwrap_or_default(); + if !crate::utils::validation::validate_redirect_destination(&payload.destination) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Destination must be a valid http(s) URL without control characters" + })), + ) + .into_response(); + } let conn = state.content_db.lock().unwrap(); match update_url( &conn, diff --git a/src/web/bulk.rs b/src/web/bulk.rs index b99531c..204348e 100644 --- a/src/web/bulk.rs +++ b/src/web/bulk.rs @@ -1,8 +1,9 @@ -use crate::auth::generate_token; -use crate::auth::password::hash_password; use crate::auth::ApiUser; +use crate::services::bulk_urls::{ + create_urls_bulk, ensure_url_quota, BulkUrlCreateItem, BulkUrlError, +}; use crate::state::AppState; -use crate::utils::get_client_ip; +use crate::utils::{get_client_ip, lock_db}; use axum::{ extract::{ConnectInfo, State}, http::{HeaderMap, StatusCode}, @@ -68,7 +69,18 @@ pub async fn api_bulk_qr( // Retrieve URLs from database let mut urls = Vec::new(); { - let conn = state.content_db.lock().unwrap(); + let conn = match lock_db(&state.content_db, "content_db") { + Ok(c) => c, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(BulkErrorResponse { + error: e.to_string(), + }), + ) + .into_response(); + } + }; for id in &payload.ids { match crate::db::content::get_url_by_id(&conn, id) { Ok(Some(url)) => urls.push(url), @@ -115,9 +127,8 @@ pub async fn api_bulk_qr( // Generate ZIP match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) { Ok(zip_data) => { - // Write Audit Log - { - let system_conn = state.db.system.lock().unwrap(); + // Write Audit Log (best-effort; do not fail the download on audit lock poison) + if let Ok(system_conn) = lock_db(&state.db.system, "system_db") { let _ = crate::db::audit_events::write_audit_event( &system_conn, user.0.username(), @@ -147,6 +158,16 @@ pub async fn api_bulk_qr( } } +fn bulk_url_error_response(err: BulkUrlError) -> Response { + let (status, msg) = match &err { + BulkUrlError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()), + BulkUrlError::Conflict(m) => (StatusCode::CONFLICT, m.clone()), + BulkUrlError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()), + BulkUrlError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()), + }; + (status, Json(BulkErrorResponse { error: msg })).into_response() +} + // POST /api/v1/bulk/url pub async fn api_bulk_url( State(state): State, @@ -185,214 +206,39 @@ pub async fn api_bulk_url( } }; - // Check quota - { - let users_conn = state.users_db.lock().unwrap(); - if let Some(quotas) = - crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None) - { - if quotas.current_urls + (payload.len() as i64) > quotas.max_urls { - return ( - StatusCode::FORBIDDEN, - Json(BulkErrorResponse { - error: "Quota limit exceeded".to_string(), - }), - ) - .into_response(); - } - } else { - return ( - StatusCode::FORBIDDEN, - Json(BulkErrorResponse { - error: "User quota not found".to_string(), - }), - ) - .into_response(); - } + if let Err(e) = ensure_url_quota(&state.users_db, target_user_id, payload.len() as i64) { + return bulk_url_error_response(e); } - let mut conn = content_db.lock().unwrap(); - let tx = match conn.transaction() { - Ok(t) => t, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(BulkErrorResponse { - error: format!("Failed to start database transaction: {}", e), - }), - ) - .into_response() - } + let items: Vec = payload + .into_iter() + .map(|item| BulkUrlCreateItem { + destination: item.destination, + code: item.code, + title: item.title, + description: item.description, + tags: item.tags, + expires_at: item.expires_at, + password: item.password, + max_access_count: item.max_access_count, + }) + .collect(); + + let created_urls = match create_urls_bulk( + &content_db, + &state.system_db, + &state.users_db, + target_user_id, + items, + ) { + Ok(urls) => urls, + Err(e) => return bulk_url_error_response(e), }; - let mut created_urls = Vec::new(); - let mut reserved_slugs: Vec = Vec::new(); - - for item in payload { - let mut code = item.code.unwrap_or_default().trim().to_lowercase(); - if code.is_empty() { - code = generate_token(3); // 6 hex - } else { - if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - let _ = tx.rollback(); - // Release reserving slugs - let system_conn = state.system_db.lock().unwrap(); - for slug in &reserved_slugs { - let _ = - crate::db::users::release_global_slug(&system_conn, slug, target_user_id); - } - return ( - StatusCode::BAD_REQUEST, - Json(BulkErrorResponse { - error: format!("Short code '{}' must be 6 hex characters", code), - }), - ) - .into_response(); - } - } - - // Reserve slug - { - let system_conn = state.system_db.lock().unwrap(); - // Check availability in system.db and also check in our currently reserved slugs in this batch - let available = crate::db::users::is_slug_available(&system_conn, &code) - .unwrap_or(false) - && !reserved_slugs.contains(&code); - - if !available { - let _ = tx.rollback(); - for slug in &reserved_slugs { - let _ = - crate::db::users::release_global_slug(&system_conn, slug, target_user_id); - } - return ( - StatusCode::CONFLICT, - Json(BulkErrorResponse { - error: format!("Short code '{}' already exists", code), - }), - ) - .into_response(); - } - - if let Err(e) = crate::db::users::register_global_slug( - &system_conn, - &code, - target_user_id, - "url", - "", - "reserving", - ) { - let _ = tx.rollback(); - for slug in &reserved_slugs { - let _ = - crate::db::users::release_global_slug(&system_conn, slug, target_user_id); - } - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(BulkErrorResponse { - error: format!("Failed to reserve slug '{}': {}", code, e), - }), - ) - .into_response(); - } - reserved_slugs.push(code.clone()); - } - - let password_hash = if let Some(ref pwd) = item.password { - match hash_password(pwd) { - Ok(h) => Some(h), - Err(e) => { - let _ = tx.rollback(); - let system_conn = state.system_db.lock().unwrap(); - for slug in &reserved_slugs { - let _ = crate::db::users::release_global_slug( - &system_conn, - slug, - target_user_id, - ); - } - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(BulkErrorResponse { - error: format!("Password hashing error: {}", e), - }), - ) - .into_response(); - } - } - } else { - None - }; - - let tags = item.tags.unwrap_or_default(); - match crate::db::content::create_url_extended( - &tx, - &code, - &item.destination, - item.title.as_deref(), - item.description.as_deref(), - &tags, - item.expires_at.as_deref(), - password_hash.as_deref(), - item.max_access_count, - ) { - Ok(url) => created_urls.push(url), - Err(e) => { - let _ = tx.rollback(); - let system_conn = state.system_db.lock().unwrap(); - for slug in &reserved_slugs { - let _ = - crate::db::users::release_global_slug(&system_conn, slug, target_user_id); - } - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(BulkErrorResponse { - error: format!("Database insert error: {}", e), - }), - ) - .into_response(); - } - } - } - - if let Err(e) = tx.commit() { - let system_conn = state.system_db.lock().unwrap(); - for slug in &reserved_slugs { - let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id); - } - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(BulkErrorResponse { - error: format!("Failed to commit transaction: {}", e), - }), - ) - .into_response(); - } - - // Activate slugs - { - let system_conn = state.system_db.lock().unwrap(); - for url in &created_urls { - let _ = system_conn.execute( - "UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;", - rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code], - ); - } - } - - // Increment quota counters - { - let users_conn = state.users_db.lock().unwrap(); - for _ in 0..created_urls.len() { - let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls"); - } - } - // Write Audit Log for the entire batch let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); - { - let system_conn = state.db.system.lock().unwrap(); + if let Ok(system_conn) = lock_db(&state.db.system, "system_db") { let _ = crate::db::audit_events::write_audit_event( &system_conn, user.0.username(), diff --git a/src/web/password_gate.rs b/src/web/password_gate.rs index 9871f2c..63d6380 100644 --- a/src/web/password_gate.rs +++ b/src/web/password_gate.rs @@ -26,6 +26,7 @@ pub async fn gate_post( State(state): State, Path(code): Path, jar: CookieJar, + headers: axum::http::HeaderMap, Form(form): Form, ) -> Response { let url_opt = match get_url_by_code(&state.db, &code) { @@ -55,8 +56,9 @@ pub async fn gate_post( if verify_password(&form.password, password_hash) { // Correct password - set 15 min temporary cookie let cookie_name = format!("bzod_gate_{}", code); + let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers); let cookie = Cookie::build((cookie_name, "authorized")) - .secure(state.config.cookie_secure) + .secure(secure_flag) .same_site(axum_extra::extract::cookie::SameSite::Strict) .http_only(true) .path("/") diff --git a/src/web/redirect.rs b/src/web/redirect.rs index 3a5be36..ab4a3fb 100644 --- a/src/web/redirect.rs +++ b/src/web/redirect.rs @@ -1,20 +1,298 @@ +//! Public short-code redirect hot path. +//! +//! Design notes: +//! - Destination `Location` headers never panic on malformed values. +//! - Content DB work uses a single mutex acquisition where safe. +//! - Expiration is enforced on the read path; persistent `expired=1` is left to +//! the background expiry job (`jobs::expiry`), not written here. +//! - Blocking rusqlite work runs in `spawn_blocking` so Tokio workers are not starved. +//! - Analytics enqueue remains non-blocking (`try_send` via the queue). + use axum::{ extract::{ConnectInfo, Path, State}, - http::{HeaderMap, StatusCode}, + http::{header, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Redirect, Response}, }; use axum_extra::extract::CookieJar; use chrono::Utc; use rusqlite::OptionalExtension; use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use tracing::{error, warn}; use uuid::Uuid; use crate::analytics::get_client_country; -use crate::models::VisitRecord; +use crate::models::{LinkPreview, Url, VisitRecord}; use crate::state::AppState; use crate::templates::PreviewTemplate; use crate::utils::get_client_ip; +/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants). +enum ResolveOutcome { + Ready(Box), + /// Early HTTP response that does not need further processing. + Early { + status: StatusCode, + body: &'static str, + }, + /// Permanent redirect to a relative path (e.g. page target → `/p/{code}`). + PermanentPath(String), + DbError { + operation: &'static str, + message: String, + owner_user_id: Option, + resource_id: Option, + }, +} + +/// Safe client-facing DB error after structured server-side logging. +fn db_error_response( + operation: &str, + code: &str, + owner_user_id: Option, + resource_id: Option<&str>, + err: impl std::fmt::Display, +) -> Response { + error!( + operation = operation, + code = code, + owner_user_id = owner_user_id, + resource_id = resource_id.unwrap_or(""), + error = %err, + "redirect path database error" + ); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() +} + +/// Build a permanent redirect without panicking on invalid destinations. +/// +/// Defense in depth: +/// 1. Canonical destination rules (http/https, no control chars) — same as writes. +/// 2. `HeaderValue` construction — rejects remaining illegal header bytes. +/// +/// Neither step may panic. Full destination values are not logged. +fn permanent_redirect_to(destination: &str, code: &str) -> Response { + if !crate::utils::validation::validate_redirect_destination(destination) { + warn!( + operation = "validate_redirect_destination", + code = code, + destination_len = destination.len(), + "invalid stored redirect destination rejected" + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid redirect destination", + ) + .into_response(); + } + + match HeaderValue::from_str(destination) { + Ok(loc) => { + let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response(); + resp.headers_mut().insert(header::LOCATION, loc); + resp + } + Err(err) => { + warn!( + operation = "build_location_header", + code = code, + error = %err, + // Do not log the full destination if it may contain control chars; + // log length only for forensics. + destination_len = destination.len(), + "invalid redirect destination rejected (possible response-splitting attempt)" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid redirect destination", + ) + .into_response() + } + } +} + +/// Result of the initial global-slug + URL resolution phase. +struct ResolvedUrl { + owner_user_id: i64, + url: Url, + content: Arc>, +} + +/// Lookup global slug namespace then load the URL from the tenant content DB. +/// Runs entirely on a blocking thread. +fn resolve_url_blocking( + system_db: Arc>, + state: AppState, + code: &str, +) -> ResolveOutcome { + // 1. Query global slug namespace in system.db + let slug_info = { + let system_conn = match system_db.lock() { + Ok(c) => c, + Err(e) => { + return ResolveOutcome::DbError { + operation: "lock_system_db", + message: e.to_string(), + owner_user_id: None, + resource_id: None, + }; + } + }; + let mut stmt = match system_conn.prepare( + "SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;", + ) { + Ok(s) => s, + Err(e) => { + return ResolveOutcome::DbError { + operation: "prepare_global_slugs", + message: e.to_string(), + owner_user_id: None, + resource_id: None, + }; + } + }; + match stmt + .query_row(rusqlite::params![code], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .optional() + { + Ok(info) => info, + Err(e) => { + return ResolveOutcome::DbError { + operation: "query_global_slugs", + message: e.to_string(), + owner_user_id: None, + resource_id: None, + }; + } + } + }; + + let (owner_user_id, target_type, _target_id, slug_status) = match slug_info { + Some(info) => info, + None => { + // Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs + (1, "url".to_string(), "".to_string(), "active".to_string()) + } + }; + + // If slug status is disabled, flagged, or soft_deleted, we return 410 Gone + if slug_status != "active" { + return ResolveOutcome::Early { + status: StatusCode::GONE, + body: "This content has been disabled or moderated", + }; + } + + // If target type is page, redirect permanently to /p/slug + if target_type == "page" { + return ResolveOutcome::PermanentPath(format!("/p/{}", code)); + } + + // 2. Get content database connection via tenant DB resolution + let content_conn = match state.get_user_dbs(owner_user_id) { + Ok(dbs) => dbs, + Err(e) => { + return ResolveOutcome::DbError { + operation: "get_user_dbs", + message: e.to_string(), + owner_user_id: Some(owner_user_id), + resource_id: None, + }; + } + }; + + let url = { + let conn = match content_conn.content.lock() { + Ok(c) => c, + Err(e) => { + return ResolveOutcome::DbError { + operation: "lock_content_db", + message: e.to_string(), + owner_user_id: Some(owner_user_id), + resource_id: None, + }; + } + }; + match crate::db::content::get_url_by_code(&conn, code) { + Ok(Some(url)) => url, + Ok(None) => { + return ResolveOutcome::Early { + status: StatusCode::NOT_FOUND, + body: "Short code not found", + }; + } + Err(e) => { + return ResolveOutcome::DbError { + operation: "get_url_by_code", + message: e.to_string(), + owner_user_id: Some(owner_user_id), + resource_id: None, + }; + } + } + }; + + ResolveOutcome::Ready(Box::new(ResolvedUrl { + owner_user_id, + url, + content: content_conn.content, + })) +} + +/// Increment access count and load preview under a single content-DB lock. +fn increment_and_preview_blocking( + content: Arc>, + url_id: &str, + code: &str, + owner_user_id: i64, + fallback_access_count: i64, +) -> Result<(i64, Option), String> { + let conn = content + .lock() + .map_err(|e| format!("lock_content_db_hot: {}", e))?; + + let new_access_count = match crate::db::content::increment_access_count(&conn, url_id) { + Ok(n) => n, + Err(e) => { + // Preserve prior soft-failure semantics for the counter value used only + // internally; never silence the underlying error. + error!( + operation = "increment_access_count", + code = code, + owner_user_id = owner_user_id, + resource_id = url_id, + error = %e, + "failed to increment access count; continuing with estimated value" + ); + fallback_access_count + 1 + } + }; + + let preview = match crate::db::preview::get_preview(&conn, url_id) { + Ok(p) => p, + Err(e) => { + error!( + operation = "get_preview", + code = code, + owner_user_id = owner_user_id, + resource_id = url_id, + error = %e, + "failed to load link preview; continuing without preview" + ); + None + } + }; + + Ok((new_access_count, preview)) +} + // GET /:code // Resolve and redirect pub async fn resolve_redirect( @@ -31,69 +309,51 @@ pub async fn resolve_redirect( return (StatusCode::NOT_FOUND, "Not Found").into_response(); } - // 1. Query global slug namespace in system.db - let slug_info = { - let system_conn = state.system_db.lock().unwrap(); - let mut stmt = match system_conn.prepare( - "SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;" - ) { - Ok(s) => s, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - stmt.query_row(rusqlite::params![code], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }) - .optional() - }; + let system_db = state.system_db.clone(); + let state_for_lookup = state.clone(); + let code_for_lookup = code.clone(); - let (owner_user_id, target_type, _target_id, slug_status) = match slug_info { - Ok(Some(info)) => info, - Ok(None) => { - // Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs - (1, "url".to_string(), "".to_string(), "active".to_string()) - } - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - // If slug status is disabled, flagged, or soft_deleted, we return 410 Gone - if slug_status != "active" { - return ( - StatusCode::GONE, - "This content has been disabled or moderated", - ) - .into_response(); - } - - // If target type is page, redirect permanently to /p/slug - if target_type == "page" { - return Redirect::permanent(&format!("/p/{}", code)).into_response(); - } - - // 2. Get content database connection via tenant DB resolution - let content_conn = match state.get_user_dbs(owner_user_id) { - Ok(dbs) => dbs.content, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let url_opt = { - let conn = content_conn.lock().unwrap(); - match crate::db::content::get_url_by_code(&conn, &code) { - Ok(url) => url, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + let outcome = match tokio::task::spawn_blocking(move || { + resolve_url_blocking(system_db, state_for_lookup, &code_for_lookup) + }) + .await + { + Ok(outcome) => outcome, + Err(e) => { + return db_error_response("spawn_blocking_resolve", &code, None, None, e.to_string()); } }; - let url = match url_opt { - Some(u) => u, - None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(), + let resolved = match outcome { + ResolveOutcome::Ready(r) => r, + ResolveOutcome::Early { status, body } => return (status, body).into_response(), + ResolveOutcome::PermanentPath(path) => { + return Redirect::permanent(&path).into_response(); + } + ResolveOutcome::DbError { + operation, + message, + owner_user_id, + resource_id, + } => { + return db_error_response( + operation, + &code, + owner_user_id, + resource_id.as_deref(), + message, + ); + } }; - // 3. Expiration check + let ResolvedUrl { + owner_user_id, + url, + content, + } = *resolved; + + // 3. Expiration check (read-only on the hot path). + // Background job `jobs::expiry::run_expiry_checker` persists expired=1. if url.expired { return (StatusCode::GONE, "This link has expired").into_response(); } @@ -101,14 +361,6 @@ pub async fn resolve_redirect( if let Some(ref expires_at_str) = url.expires_at { if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) { if expires_at.with_timezone(&Utc) < Utc::now() { - // Mark as expired in DB asynchronously/immediately - { - let conn = content_conn.lock().unwrap(); - let _ = conn.execute( - "UPDATE urls SET expired = 1 WHERE id = ?1;", - [url.id.clone()], - ); - } return (StatusCode::GONE, "This link has expired").into_response(); } } @@ -136,15 +388,40 @@ pub async fn resolve_redirect( } } - // 6. Increment access count & retrieve preview config - let _new_access_count = { - let conn = content_conn.lock().unwrap(); - crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1) - }; - - let preview_opt = { - let conn = content_conn.lock().unwrap(); - crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None) + // 6. Increment access count & retrieve preview config (single content lock, off executor) + let url_id = url.id.clone(); + let code_for_hot = code.clone(); + let fallback_access_count = url.access_count; + let preview_opt = match tokio::task::spawn_blocking(move || { + increment_and_preview_blocking( + content, + &url_id, + &code_for_hot, + owner_user_id, + fallback_access_count, + ) + }) + .await + { + Ok(Ok((_new_access_count, preview))) => preview, + Ok(Err(msg)) => { + return db_error_response( + "increment_and_preview", + &code, + Some(owner_user_id), + Some(&url.id), + msg, + ); + } + Err(e) => { + return db_error_response( + "spawn_blocking_hot", + &code, + Some(owner_user_id), + Some(&url.id), + e.to_string(), + ); + } }; // Asynchronously record analytics @@ -195,14 +472,32 @@ pub async fn resolve_redirect( } .into_response() } else { - { - use axum::http::{header, HeaderValue}; - let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response(); - resp.headers_mut().insert( - header::LOCATION, - HeaderValue::from_str(&url.destination).unwrap(), - ); - resp - } + permanent_redirect_to(&url.destination, &code) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permanent_redirect_rejects_crlf() { + let resp = permanent_redirect_to("https://evil.example/\r\nX-Injected: yes", "abc123"); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(resp.headers().get(header::LOCATION).is_none()); + } + + #[test] + fn permanent_redirect_rejects_control_chars() { + let resp = permanent_redirect_to("https://evil.example/\x00payload", "abc123"); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn permanent_redirect_accepts_valid_url() { + let resp = permanent_redirect_to("https://example.com/path?q=1", "abc123"); + assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY); + let loc = resp.headers().get(header::LOCATION).unwrap(); + assert_eq!(loc, "https://example.com/path?q=1"); } } diff --git a/tests/admin_user_management_tests.rs b/tests/admin_user_management_tests.rs index 0f48a06..7b6ab67 100644 --- a/tests/admin_user_management_tests.rs +++ b/tests/admin_user_management_tests.rs @@ -28,7 +28,9 @@ fn create_temp_config(temp_dir: PathBuf) -> Config { fn build_state(config: Config) -> (Db, AppState) { let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 1000); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx); let state = AppState { admin_db: db.admin.clone(), content_db: db.content.clone(), diff --git a/tests/analytics_parity_tests.rs b/tests/analytics_parity_tests.rs index 52719d4..bf03766 100644 --- a/tests/analytics_parity_tests.rs +++ b/tests/analytics_parity_tests.rs @@ -44,7 +44,9 @@ async fn start_test_server( ) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) { let config = create_temp_config(temp_dir); let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 100); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx); let state = AppState { admin_db: db.admin.clone(), diff --git a/tests/auth_migration_tests.rs b/tests/auth_migration_tests.rs index 551a29b..d53ba58 100644 --- a/tests/auth_migration_tests.rs +++ b/tests/auth_migration_tests.rs @@ -37,7 +37,9 @@ fn compute_sha256(value: &str) -> String { fn build_state(config: Config) -> (Db, bzod::state::AppState) { let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 1000); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx); let state = bzod::state::AppState { admin_db: db.admin.clone(), content_db: db.content.clone(), diff --git a/tests/business_workflow_tests.rs b/tests/business_workflow_tests.rs index a159a1d..51fffe4 100644 --- a/tests/business_workflow_tests.rs +++ b/tests/business_workflow_tests.rs @@ -49,7 +49,9 @@ async fn start_test_server( ) { let config = create_temp_config(temp_dir); let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 10); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx); let state = AppState { admin_db: db.admin.clone(), diff --git a/tests/http_e2e_tests.rs b/tests/http_e2e_tests.rs index d48bbf9..57134f3 100644 --- a/tests/http_e2e_tests.rs +++ b/tests/http_e2e_tests.rs @@ -23,7 +23,10 @@ fn create_temp_config(temp_dir: PathBuf) -> Config { config.backup_dir = temp_dir.clone(); config.admin_username = "admin".to_string(); config.base_url = Some("http://localhost:8080".to_string()); - config.cookie_secure = false; // Disable secure flag for testing over HTTP loopback + // We leave config.cookie_secure as default (true). + // The new resolve_cookie_secure logic will automatically drop Secure + // for HTTP requests over the 127.0.0.1 loopback during this test, + // proving the local development fix works end-to-end. config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret"); config } @@ -45,7 +48,9 @@ async fn start_test_server( ) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) { let config = create_temp_config(temp_dir); let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 100); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx); let state = AppState { admin_db: db.admin.clone(), diff --git a/tests/legacy_restore_tests.rs b/tests/legacy_restore_tests.rs new file mode 100644 index 0000000..988207d --- /dev/null +++ b/tests/legacy_restore_tests.rs @@ -0,0 +1,632 @@ +//! Tests for legacy_flat_backup restore compatibility and current backup roundtrip. +//! +//! These tests use a synthetic fixture that reproduces the exact structure of +//! a real legacy_flat_backup archive: +//! - admin.db with a users table (TEXT UUID PK, username, password_hash) +//! - users.db that is completely empty (no tables, user_version=0) +//! - system.db with global_slugs referencing multiple owner_user_ids +//! - content.db with URLs and landing pages +//! - analytics.db with visits +//! - backup_manifest.json with type "legacy_flat_backup" +//! - Orphaned slug entries (in global_slugs but not in content.db) +//! - A missing tenant (owner_user_id=3 whose databases are not included) + +use bzod::config::Config; +use bzod::db::Db; +use flate2::write::GzEncoder; +use flate2::Compression; +use rusqlite::Connection; +use std::fs; +use std::path::PathBuf; +use tar::Builder; + +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 +} + +/// Build a synthetic legacy_flat_backup .tar.gz archive that reproduces the +/// exact structure of the real production backup that fails with: +/// "Failed to verify registry integrity in backup: no such table: users" +/// +/// IMPORTANT: This uses purely synthetic data — no real credentials, URLs, +/// audit logs, or analytics from the production backup are included. +fn build_synthetic_legacy_fixture(output_path: &std::path::Path) { + use chrono::Utc; + let fixture_dir = + std::env::temp_dir().join(format!("bzod_fixture_build_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&fixture_dir).unwrap(); + + let now = Utc::now().to_rfc3339(); + + // --- admin.db: legacy admin schema with TEXT UUID primary key --- + { + let conn = Connection::open(fixture_dir.join("admin.db")).unwrap(); + conn.execute_batch( + "CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT + ); + CREATE TABLE audit_logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + object_type TEXT, + object_id TEXT, + ip_address TEXT, + user_agent TEXT + ); + CREATE TABLE config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );", + ) + .unwrap(); + + // Insert a synthetic admin with a known argon2id hash + // (this is NOT a real password hash — it's a valid format placeholder) + let admin_hash = + "$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000"; + conn.execute( + "INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);", + rusqlite::params![ + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "admin", + admin_hash, + &now + ], + ) + .unwrap(); + + // Insert an audit log entry + conn.execute( + "INSERT INTO audit_logs (id, timestamp, username, action) VALUES (?1, ?2, ?3, ?4);", + rusqlite::params!["audit-1", &now, "admin", "LOGIN"], + ) + .unwrap(); + + // Set user_version to 1 (matching legacy migration state) + conn.execute_batch("PRAGMA user_version = 1;").unwrap(); + } + + // --- system.db: has global_slugs with multiple owners + orphaned entries --- + { + let mut conn = Connection::open(fixture_dir.join("system.db")).unwrap(); + // Run system migrations to get the full schema + bzod::db::migrations::run_migrations( + &mut conn, + "system", + bzod::db::migrations::SYSTEM_MIGRATIONS, + None, + ) + .unwrap(); + + // Insert global_slugs owned by user_id=1 (content exists) + for (slug, target_type, target_id) in &[ + ("abc123", "url", "url-id-1"), + ("def456", "url", "url-id-2"), + ("!custom-slug", "url", "url-id-3"), + ("!test-page", "page", "page-id-1"), + ("!meeting", "page", "page-id-2"), + ] { + conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES (?1, 1, ?2, ?3, ?4, ?5, 'active');", + rusqlite::params![slug, target_type, target_id, &now, &now], + ) + .unwrap(); + } + + // Insert global_slugs owned by user_id=3 (tenant NOT included in flat backup) + for (slug, target_type, target_id) in &[ + ("xyz789", "url", "user3-url-1"), + ("!user3-page", "page", "user3-page-1"), + ] { + conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES (?1, 3, ?2, ?3, ?4, ?5, 'active');", + rusqlite::params![slug, target_type, target_id, &now, &now], + ) + .unwrap(); + } + + // Insert an orphaned slug (user_id=1, content doesn't exist) + conn.execute( + "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES ('orphan-slug', 1, 'url', 'nonexistent-id', ?1, ?2, 'active');", + rusqlite::params![&now, &now], + ) + .unwrap(); + } + + // --- users.db: completely empty (no schema, user_version=0) --- + { + let _conn = Connection::open(fixture_dir.join("users.db")).unwrap(); + // Intentionally empty — this is the root cause of the original bug + } + + // --- content.db: URLs and landing pages belonging to user_id=1 --- + { + let mut conn = Connection::open(fixture_dir.join("content.db")).unwrap(); + bzod::db::migrations::run_migrations( + &mut conn, + "content", + bzod::db::migrations::CONTENT_MIGRATIONS, + None, + ) + .unwrap(); + + // Insert URLs + for (id, code, dest) in &[ + ("url-id-1", "abc123", "https://example.com/1"), + ("url-id-2", "def456", "https://example.com/2"), + ("url-id-3", "!custom-slug", "https://example.com/3"), + ] { + conn.execute( + "INSERT INTO urls (id, code, destination, status, created_at, updated_at) + VALUES (?1, ?2, ?3, 'active', ?4, ?5);", + rusqlite::params![id, code, dest, &now, &now], + ) + .unwrap(); + } + + // Insert landing pages + for (id, code, title) in &[ + ("page-id-1", "!test-page", "Test Page"), + ("page-id-2", "!meeting", "Meeting Notes"), + ] { + conn.execute( + "INSERT INTO landing_pages (id, code, slug, title, html_content, state, created_at, updated_at) + VALUES (?1, ?2, ?2, ?3, '

Test

', 'published', ?4, ?5);", + rusqlite::params![id, code, title, &now, &now], + ) + .unwrap(); + } + } + + // --- analytics.db: visits --- + { + let mut conn = Connection::open(fixture_dir.join("analytics.db")).unwrap(); + bzod::db::migrations::run_migrations( + &mut conn, + "analytics", + bzod::db::migrations::ANALYTICS_MIGRATIONS, + None, + ) + .unwrap(); + + // Insert some visits + for i in 0..10 { + conn.execute( + "INSERT INTO visits (id, target_type, target_id, timestamp, owner_user_id, ip_address, user_agent, referer, accept_language, country, status_code) + VALUES (?1, 'url', 'url-id-1', ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8);", + rusqlite::params![format!("visit-{}", i), &now, "127.0.0.1", "test-agent", "", "en-US", "US", 200], + ) + .unwrap(); + } + } + + // --- backup_manifest.json --- + let manifest = serde_json::json!({ + "created_at": &now, + "type": "legacy_flat_backup", + "files_included": ["admin.db", "system.db", "users.db", "content.db", "analytics.db"], + "note": "Multi-tenant databases flattened for backward compatibility.", + }); + fs::write( + fixture_dir.join("backup_manifest.json"), + manifest.to_string(), + ) + .unwrap(); + + // --- Package into .tar.gz --- + let tar_file = fs::File::create(output_path).unwrap(); + let enc = GzEncoder::new(tar_file, Compression::default()); + let mut tar = Builder::new(enc); + + for name in &[ + "admin.db", + "system.db", + "users.db", + "content.db", + "analytics.db", + "backup_manifest.json", + ] { + tar.append_path_with_name(fixture_dir.join(name), name) + .unwrap(); + } + + tar.into_inner().unwrap().finish().unwrap(); + let _ = fs::remove_dir_all(&fixture_dir); +} + +// ========================================================================== +// Test 1: Legacy flat backup restores without "no such table" error +// ========================================================================== +#[test] +fn test_legacy_flat_backup_restore() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_legacy_restore_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + + let fixture_path = temp_dir.join("legacy-backup.tar.gz"); + build_synthetic_legacy_fixture(&fixture_path); + + let restore_dir = temp_dir.join("restored_data"); + fs::create_dir_all(&restore_dir).unwrap(); + + // This must succeed — previously it failed with "no such table: users" + let result = bzod::cli::restore::perform_restore(&fixture_path, &restore_dir); + assert!( + result.is_ok(), + "Legacy flat backup restore failed: {:?}", + result.err() + ); + + // Verify multi-tenant directory structure + assert!( + restore_dir.join("admin/admin.db").exists(), + "admin/admin.db missing" + ); + assert!( + restore_dir.join("admin/system.db").exists(), + "admin/system.db missing" + ); + assert!( + restore_dir.join("admin/users.db").exists(), + "admin/users.db missing" + ); + assert!( + restore_dir.join("users/1/content.db").exists(), + "users/1/content.db missing" + ); + assert!( + restore_dir.join("users/1/analytics.db").exists(), + "users/1/analytics.db missing" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +// ========================================================================== +// Test 2: Admin credentials are preserved, not manufactured +// ========================================================================== +#[test] +fn test_legacy_restore_preserves_admin_credentials() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_legacy_creds_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + + let fixture_path = temp_dir.join("legacy-backup.tar.gz"); + build_synthetic_legacy_fixture(&fixture_path); + + let restore_dir = temp_dir.join("restored_data"); + fs::create_dir_all(&restore_dir).unwrap(); + + bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap(); + + let expected_hash = + "$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000"; + + // Verify original admin identity in admin.db is untouched + { + let conn = Connection::open(restore_dir.join("admin/admin.db")).unwrap(); + let (username, hash): (String, String) = conn + .query_row( + "SELECT username, password_hash FROM users WHERE id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(username, "admin"); + assert_eq!(hash, expected_hash, "Admin password hash was modified!"); + } + + // Verify users.db was bootstrapped with actual admin credentials + { + let conn = Connection::open(restore_dir.join("admin/users.db")).unwrap(); + + // legacy_admin system placeholder should exist with id=1 + let (la_username, la_hash, la_type): (String, String, String) = conn + .query_row( + "SELECT username, password_hash, account_type FROM users WHERE id = 1;", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(la_username, "legacy_admin"); + assert_eq!( + la_hash, expected_hash, + "legacy_admin hash should match original admin" + ); + assert_eq!(la_type, "system"); + + // Actual admin account should exist with original credentials + let (admin_hash, admin_type, admin_status): (String, String, String) = conn + .query_row( + "SELECT password_hash, account_type, status FROM users WHERE username = 'admin';", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!( + admin_hash, expected_hash, + "Admin account hash should match original" + ); + assert_eq!(admin_type, "admin"); + assert_eq!(admin_status, "active"); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +// ========================================================================== +// Test 3: Functional data is preserved and accessible after restore + Db::init() +// ========================================================================== +#[tokio::test] +async fn test_legacy_restore_functional_data() { + let temp_dir = std::env::temp_dir().join(format!( + "bzod_test_legacy_functional_{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&temp_dir).unwrap(); + + let fixture_path = temp_dir.join("legacy-backup.tar.gz"); + build_synthetic_legacy_fixture(&fixture_path); + + let restore_dir = temp_dir.join("restored_data"); + fs::create_dir_all(&restore_dir).unwrap(); + + bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap(); + + // Initialize Db against the restored data (simulates fresh v0.6.0 startup) + let config = create_temp_config(restore_dir.clone()); + let db = Db::init(&config).expect("Db::init failed on restored legacy data"); + + // Verify URLs + { + let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap(); + let url_count: i64 = content_conn + .query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0)) + .unwrap(); + assert_eq!(url_count, 3, "Expected 3 URLs in restored content.db"); + + let url = bzod::db::content::get_url_by_code(&content_conn, "abc123") + .unwrap() + .unwrap(); + assert_eq!(url.destination, "https://example.com/1"); + } + + // Verify landing pages + { + let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap(); + let page_count: i64 = content_conn + .query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + page_count, 2, + "Expected 2 landing pages in restored content.db" + ); + } + + // Verify analytics + { + let analytics_conn = bzod::jobs::open_user_analytics_conn(&db, 1).unwrap(); + let visit_count: i64 = analytics_conn + .query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + visit_count, 10, + "Expected 10 visits in restored analytics.db" + ); + } + + // Verify global slug registry + { + let system_conn = db.system.lock().unwrap(); + let slug_count: i64 = system_conn + .query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0)) + .unwrap(); + assert!( + slug_count >= 7, + "Expected at least 7 global_slugs (5 user1 + 2 user3 + orphan)" + ); + } + + // Verify user 3 placeholder exists + { + let users_conn = db.users.lock().unwrap(); + let user3_exists: bool = users_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = 3);", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + user3_exists, + "Placeholder for user_id=3 should exist in users.db" + ); + + let (status, metadata): (String, Option) = users_conn + .query_row( + "SELECT status, metadata FROM users WHERE id = 3;", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(status, "disabled", "User 3 placeholder should be disabled"); + assert!( + metadata.as_deref().unwrap_or("").contains("Placeholder"), + "User 3 metadata should document it as a placeholder" + ); + } + + // Verify admin identity in admin.db + { + let admin_conn = db.admin.lock().unwrap(); + let admin_exists: bool = admin_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin');", + [], + |r| r.get(0), + ) + .unwrap(); + assert!( + admin_exists, + "Original admin identity must be preserved in admin.db" + ); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +// ========================================================================== +// Test 4: Current/native backup restore roundtrip +// ========================================================================== +#[tokio::test] +async fn test_current_backup_restore_roundtrip() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_current_rt_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + + let db = Db::init(&config).expect("Failed to init Db"); + + // Create a user and add content + let _ = bzod::cli::create_user::run( + Some("testuser".to_string()), + Some("password123".to_string()), + None, + config.clone(), + ) + .await + .unwrap(); + + let user_id = { + let conn = db.users.lock().unwrap(); + bzod::db::users::get_user_by_username(&conn, "testuser") + .unwrap() + .unwrap() + .id + }; + + { + let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap(); + bzod::db::content::create_url_extended( + &user_content_conn, + "!current-test", + "https://example.com/current", + None, + None, + &vec![], + None, + None, + None, + ) + .unwrap(); + + let system_conn = db.system.lock().unwrap(); + bzod::db::users::register_global_slug( + &system_conn, + "!current-test", + user_id, + "url", + "current-id", + "active", + ) + .unwrap(); + } + + // Create a native backup + let backup_path = bzod::jobs::backup::perform_backup(&db, &config) + .await + .unwrap(); + + // Restore to a fresh directory + let restore_dir = temp_dir.join("restored_native"); + fs::create_dir_all(&restore_dir).unwrap(); + + bzod::cli::restore::perform_restore(std::path::Path::new(&backup_path), &restore_dir).unwrap(); + + // Verify restored data + let restore_config = create_temp_config(restore_dir.clone()); + let restored_db = Db::init(&restore_config).expect("Db::init failed on restored native data"); + + { + let conn = restored_db.users.lock().unwrap(); + let user = bzod::db::users::get_user_by_username(&conn, "testuser") + .unwrap() + .unwrap(); + assert_eq!(user.status, "active"); + } + + { + let content_conn = bzod::jobs::open_user_content_conn(&restored_db, user_id).unwrap(); + let url = bzod::db::content::get_url_by_code(&content_conn, "!current-test") + .unwrap() + .unwrap(); + assert_eq!(url.destination, "https://example.com/current"); + } + + let _ = fs::remove_dir_all(&temp_dir); +} + +// ========================================================================== +// Test 5: Failed restore does not corrupt existing data +// ========================================================================== +#[tokio::test] +async fn test_failed_restore_does_not_corrupt() { + let temp_dir = + std::env::temp_dir().join(format!("bzod_test_safe_restore_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + + // Set up a working installation + let _db = Db::init(&config).expect("Failed to init Db"); + + // Write a sentinel file to verify the data dir survives + let sentinel = config.data_dir.join("admin").join("sentinel.txt"); + fs::write(&sentinel, "intact").unwrap(); + + // Create a corrupt "backup" file + let corrupt_path = temp_dir.join("corrupt.tar.gz"); + fs::write(&corrupt_path, b"this is not a valid tar.gz file").unwrap(); + + // Attempt restore — must fail + let result = bzod::cli::restore::perform_restore(&corrupt_path, &config.data_dir); + assert!(result.is_err(), "Corrupt backup should fail to restore"); + + // Verify original data is intact + assert!( + sentinel.exists(), + "Sentinel file must survive failed restore" + ); + assert_eq!( + fs::read_to_string(&sentinel).unwrap(), + "intact", + "Sentinel file content must be unchanged" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} diff --git a/tests/ownership_tests.rs b/tests/ownership_tests.rs index 9c09392..dbb6529 100644 --- a/tests/ownership_tests.rs +++ b/tests/ownership_tests.rs @@ -44,7 +44,9 @@ async fn start_test_server( ) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) { let config = create_temp_config(temp_dir); let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 100); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx); let state = AppState { admin_db: db.admin.clone(), diff --git a/tests/qr_endpoint_tests.rs b/tests/qr_endpoint_tests.rs index 42e8697..87ff46b 100644 --- a/tests/qr_endpoint_tests.rs +++ b/tests/qr_endpoint_tests.rs @@ -33,7 +33,9 @@ async fn start_test_server( ) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) { let config = create_temp_config(temp_dir); let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 100); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx); let state = AppState { admin_db: db.admin.clone(), diff --git a/tests/redirect_security_tests.rs b/tests/redirect_security_tests.rs new file mode 100644 index 0000000..a9bc3c4 --- /dev/null +++ b/tests/redirect_security_tests.rs @@ -0,0 +1,743 @@ +//! Redirect security & behavioral regression tests (Phase 2). +//! +//! Covers: 301, legacy malicious destinations, expiration, access limits, +//! password gate ordering, previews, tenant slug isolation, concurrent access. + +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; +use tokio::net::TcpListener; +use tokio::sync::Barrier; + +use bzod::analytics::AnalyticsQueue; +use bzod::auth::password::hash_password; +use bzod::config::Config; +use bzod::db::Db; +use bzod::services::destination_audit::{ + audit_all_destinations, audit_content_conn, DestinationAuditReport, +}; +use bzod::state::AppState; +use bzod::web::create_router; + +fn compute_sha256(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hex::encode(hasher.finalize()) +} + +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.admin_username = "admin".to_string(); + config.base_url = Some("http://localhost:8080".to_string()); + config.cookie_secure = false; + config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret"); + config +} + +async fn start_test_server( + temp_dir: PathBuf, +) -> (reqwest::Client, String, Db, tokio::task::JoinHandle<()>) { + let config = create_temp_config(temp_dir); + let db = Db::init(&config).expect("Failed to init Db"); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx); + + let state = AppState { + admin_db: db.admin.clone(), + content_db: db.content.clone(), + analytics_db: db.analytics.clone(), + system_db: db.system.clone(), + users_db: db.users.clone(), + user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())), + db: db.clone(), + config, + analytics_queue: queue, + start_time: Instant::now(), + }; + + let router = create_router(state); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{}", addr); + + let handle = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let client = reqwest::Client::builder() + .cookie_store(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + (client, url, db, handle) +} + +struct SeedUrl<'a> { + owner_user_id: i64, + code: &'a str, + destination: &'a str, + expired: bool, + expires_at: Option<&'a str>, + password_hash: Option<&'a str>, + max_access_count: Option, + access_count: i64, +} + +fn seed_url(db: &Db, seed: SeedUrl<'_>) { + // Ensure user content DB exists + db.init_user_databases(seed.owner_user_id) + .expect("init user dbs"); + + { + let system = db.system.lock().unwrap(); + let _ = bzod::db::users::register_global_slug( + &system, + seed.code, + seed.owner_user_id, + "url", + "seed", + "active", + ); + } + + let content_path = db + .data_dir + .join("users") + .join(seed.owner_user_id.to_string()) + .join("content.db"); + let conn = rusqlite::Connection::open(content_path).unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, max_access_count, access_count) + VALUES (?1, ?2, ?3, NULL, NULL, 'healthy', ?4, ?4, ?5, ?6, ?7, ?8, ?9);", + rusqlite::params![ + id, + seed.code, + seed.destination, + now, + seed.expires_at, + if seed.expired { 1 } else { 0 }, + seed.password_hash, + seed.max_access_count, + seed.access_count, + ], + ) + .unwrap(); +} + +#[tokio::test] +async fn valid_destination_returns_301() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_301_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "a1b2c3", + destination: "https://example.com/target", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + let res = client.get(format!("{}/a1b2c3", base)).send().await.unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY); + assert_eq!( + res.headers().get("location").unwrap().to_str().unwrap(), + "https://example.com/target" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn legacy_crlf_destination_fails_closed_no_location() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_crlf_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + // Simulate legacy DB row that bypassed modern write validation. + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "dead01", + destination: "https://evil.example/\r\nX-Injected: yes", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + let res = client.get(format!("{}/dead01", base)).send().await.unwrap(); + // Must not panic; fail closed without Location header. + assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); + assert!(res.headers().get("location").is_none()); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn javascript_scheme_legacy_fails_closed() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_js_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab1111", + destination: "javascript:alert(1)", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + let res = client.get(format!("{}/ab1111", base)).send().await.unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); + assert!(res.headers().get("location").is_none()); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn expired_flag_returns_410_without_redirect() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_exp_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab2222", + destination: "https://example.com/gone", + expired: true, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + let res = client.get(format!("{}/ab2222", base)).send().await.unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::GONE); + assert!(res.headers().get("location").is_none()); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn wall_clock_expiry_returns_410_without_hot_path_write_dependency() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_exp2_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + // expired=0 but expires_at in the past → still 410 (read-path authoritative). + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab3333", + destination: "https://example.com/gone2", + expired: false, + expires_at: Some("2000-01-01T00:00:00Z"), + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + let res = client.get(format!("{}/ab3333", base)).send().await.unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::GONE); + + // Column may still be 0 until sweeper runs — correctness does not require write. + let content_path = db.data_dir.join("users/1/content.db"); + let conn = rusqlite::Connection::open(content_path).unwrap(); + let expired_flag: i64 = conn + .query_row("SELECT expired FROM urls WHERE code = 'ab3333';", [], |r| { + r.get(0) + }) + .unwrap(); + // Either 0 (hot path no write) or 1 is acceptable if something else flipped it; + // the important assertion is 410 above. Prefer documenting no write: + assert!( + expired_flag == 0 || expired_flag == 1, + "unexpected expired flag {}", + expired_flag + ); + // Phase 2 guarantee: no hot-path write required — if still 0, sweeper is maintenance only. + assert_eq!( + expired_flag, 0, + "redirect hot path must not persist expired=1" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn access_limit_exhausted_returns_410() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_lim_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab4444", + destination: "https://example.com/limited", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: Some(2), + access_count: 2, // already exhausted + }, + ); + + let res = client.get(format!("{}/ab4444", base)).send().await.unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::GONE); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn password_gate_before_access_increment() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_pw_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + let hash = hash_password("secret-pass").unwrap(); + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab5555", + destination: "https://example.com/secret", + expired: false, + expires_at: None, + password_hash: Some(&hash), + max_access_count: None, + access_count: 0, + }, + ); + + let res = client.get(format!("{}/ab5555", base)).send().await.unwrap(); + assert!(res.status().is_redirection()); + let loc = res.headers().get("location").unwrap().to_str().unwrap(); + assert!(loc.contains("/gate/ab5555")); + + // Access count must not have incremented + let content_path = db.data_dir.join("users/1/content.db"); + let conn = rusqlite::Connection::open(content_path).unwrap(); + let count: i64 = conn + .query_row( + "SELECT access_count FROM urls WHERE code = 'ab5555';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn concurrent_redirects_increment_access_count() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_conc_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab6666", + destination: "https://example.com/concurrent", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + const N: usize = 20; + let barrier = Arc::new(Barrier::new(N)); + let mut handles = Vec::new(); + for _ in 0..N { + let client = client.clone(); + let url = format!("{}/ab6666", base); + let b = barrier.clone(); + handles.push(tokio::spawn(async move { + b.wait().await; + client.get(&url).send().await.unwrap() + })); + } + + let mut ok_301 = 0; + for h in handles { + let res = h.await.unwrap(); + if res.status() == reqwest::StatusCode::MOVED_PERMANENTLY { + ok_301 += 1; + } + } + assert_eq!(ok_301, N); + + let content_path = db.data_dir.join("users/1/content.db"); + let conn = rusqlite::Connection::open(content_path).unwrap(); + let count: i64 = conn + .query_row( + "SELECT access_count FROM urls WHERE code = 'ab6666';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, N as i64, + "each successful redirect should increment access_count once" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn tenant_slug_resolves_owner_not_cross_tenant_content() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_ten_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + // Create two users + let config = create_temp_config(temp_dir.clone()); + let _ = bzod::cli::create_user::run( + Some("alice".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await; + let _ = bzod::cli::create_user::run( + Some("bob".into()), + Some("password123".into()), + None, + config.clone(), + ) + .await; + + let (alice_id, bob_id) = { + let conn = db.users.lock().unwrap(); + let a = bzod::db::users::get_user_by_username(&conn, "alice") + .unwrap() + .unwrap() + .id; + let b = bzod::db::users::get_user_by_username(&conn, "bob") + .unwrap() + .unwrap() + .id; + (a, b) + }; + + seed_url( + &db, + SeedUrl { + owner_user_id: alice_id, + code: "!alice1", + destination: "https://alice.example/ok", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + seed_url( + &db, + SeedUrl { + owner_user_id: bob_id, + code: "!bob001", + destination: "https://bob.example/ok", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + let res_a = client + .get(format!("{}/!alice1", base)) + .send() + .await + .unwrap(); + assert_eq!(res_a.status(), reqwest::StatusCode::MOVED_PERMANENTLY); + assert_eq!( + res_a.headers().get("location").unwrap().to_str().unwrap(), + "https://alice.example/ok" + ); + + let res_b = client + .get(format!("{}/!bob001", base)) + .send() + .await + .unwrap(); + assert_eq!(res_b.status(), reqwest::StatusCode::MOVED_PERMANENTLY); + assert_eq!( + res_b.headers().get("location").unwrap().to_str().unwrap(), + "https://bob.example/ok" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn destination_audit_finds_legacy_invalid_without_rewriting() { + let temp_dir = std::env::temp_dir().join(format!("bzod_audit_dest_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let config = create_temp_config(temp_dir.clone()); + let db = Db::init(&config).unwrap(); + + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab9999", + destination: "https://example.com/good", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "bad001", + destination: "https://evil/\r\nX:1", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "bad002", + destination: "javascript:alert(1)", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + let report = audit_all_destinations(&db).unwrap(); + assert_eq!(report.total_urls, 3); + assert_eq!(report.valid_https, 1); + assert_eq!(report.invalid, 2); + assert_eq!(report.control_characters, 1); + assert_eq!(report.unsupported_scheme, 1); + + // Data not rewritten + let content_path = db.data_dir.join("users/1/content.db"); + let conn = rusqlite::Connection::open(content_path).unwrap(); + let dest: String = conn + .query_row( + "SELECT destination FROM urls WHERE code = 'bad001';", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(dest.contains('\r')); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn disabled_slug_returns_410() { + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_dis_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab7777", + destination: "https://example.com/x", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + { + let system = db.system.lock().unwrap(); + system + .execute( + "UPDATE global_slugs SET status = 'disabled' WHERE slug = 'ab7777';", + [], + ) + .unwrap(); + } + + let res = client.get(format!("{}/ab7777", base)).send().await.unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::GONE); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn redirect_performance_smoke() { + // Not a CI gate for absolute latency — documents methodology and asserts + // correctness under concurrent load (error rate = 0, access counts match). + let temp_dir = std::env::temp_dir().join(format!("bzod_redir_perf_{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&temp_dir).unwrap(); + let (client, base, db, _h) = start_test_server(temp_dir.clone()).await; + + seed_url( + &db, + SeedUrl { + owner_user_id: 1, + code: "ab8888", + destination: "https://example.com/perf", + expired: false, + expires_at: None, + password_hash: None, + max_access_count: None, + access_count: 0, + }, + ); + + // Warm-up + for _ in 0..10 { + let _ = client.get(format!("{}/ab8888", base)).send().await.unwrap(); + } + // Reset access count after warm-up for clean correctness check + { + let content_path = db.data_dir.join("users/1/content.db"); + let conn = rusqlite::Connection::open(content_path).unwrap(); + conn.execute( + "UPDATE urls SET access_count = 0 WHERE code = 'ab8888';", + [], + ) + .unwrap(); + } + + const REQUESTS: usize = 200; + const CONCURRENCY: usize = 20; + let mut latencies_ms: Vec = Vec::with_capacity(REQUESTS); + let mut errors = 0usize; + + let mut remaining = REQUESTS; + while remaining > 0 { + let batch = remaining.min(CONCURRENCY); + let mut handles = Vec::with_capacity(batch); + for _ in 0..batch { + let client = client.clone(); + let url = format!("{}/ab8888", base); + handles.push(tokio::spawn(async move { + let start = Instant::now(); + let res = client.get(&url).send().await; + let elapsed = start.elapsed().as_secs_f64() * 1000.0; + (res, elapsed) + })); + } + for h in handles { + match h.await.unwrap() { + (Ok(res), ms) if res.status() == reqwest::StatusCode::MOVED_PERMANENTLY => { + latencies_ms.push(ms); + } + _ => errors += 1, + } + } + remaining -= batch; + } + + latencies_ms.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let p = |q: f64| { + let idx = ((latencies_ms.len() as f64 - 1.0) * q).round() as usize; + latencies_ms[idx] + }; + let p50 = p(0.50); + let p95 = p(0.95); + let p99 = p(0.99); + let throughput = REQUESTS as f64 + / (latencies_ms.iter().sum::() / CONCURRENCY as f64 / 1000.0).max(0.001); + + eprintln!("=== redirect_performance_smoke ==="); + eprintln!("env: local loopback, SQLite WAL, warm connections"); + eprintln!("requests={}, concurrency={}", REQUESTS, CONCURRENCY); + eprintln!( + "p50={:.3}ms p95={:.3}ms p99={:.3}ms errors={} approx_rps={:.1}", + p50, p95, p99, errors, throughput + ); + eprintln!("baseline comparison: UNAVAILABLE (no git history in workspace)"); + + assert_eq!(errors, 0, "error rate must be zero"); + assert_eq!(latencies_ms.len(), REQUESTS); + + let content_path = db.data_dir.join("users/1/content.db"); + let conn = rusqlite::Connection::open(content_path).unwrap(); + let count: i64 = conn + .query_row( + "SELECT access_count FROM urls WHERE code = 'ab8888';", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, REQUESTS as i64); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn audit_content_conn_unit_path() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE urls ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + destination TEXT NOT NULL + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO urls VALUES ('1','a','https://ok.example/');", + [], + ) + .unwrap(); + conn.execute("INSERT INTO urls VALUES ('2','b','javascript:x');", []) + .unwrap(); + + let mut report = DestinationAuditReport::default(); + audit_content_conn(&conn, 9, &mut report).unwrap(); + assert_eq!(report.total_urls, 2); + assert_eq!(report.valid_https, 1); + assert_eq!(report.unsupported_scheme, 1); +} diff --git a/tests/routing_tests.rs b/tests/routing_tests.rs index 3d6b497..432807a 100644 --- a/tests/routing_tests.rs +++ b/tests/routing_tests.rs @@ -25,7 +25,9 @@ async fn test_global_slug_lookup_and_redirection() { let config = create_temp_config(temp_dir.clone()); let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 1000); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx); let state = AppState { admin_db: db.admin.clone(), @@ -120,7 +122,9 @@ async fn test_disabled_slug_returns_410() { let config = create_temp_config(temp_dir.clone()); let db = Db::init(&config).expect("Failed to init Db"); - let queue = AnalyticsQueue::new(db.clone(), 1000); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx); let state = AppState { admin_db: db.admin.clone(), diff --git a/tests/upgrade_validation_tests.rs b/tests/upgrade_validation_tests.rs index 977130b..29d16e4 100644 --- a/tests/upgrade_validation_tests.rs +++ b/tests/upgrade_validation_tests.rs @@ -148,7 +148,9 @@ async fn test_upgrade_from_v0_4_0() { assert!(temp_dir.join("users/1/analytics.db").exists()); // Spawn server - let queue = AnalyticsQueue::new(db.clone(), 10); + let (tx, rx) = tokio::sync::watch::channel(false); + Box::leak(Box::new(tx)); + let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx); let state = AppState { admin_db: db.admin.clone(), content_db: db.content.clone(),