From 115f6e9a23129d7e451e5ac36c11e40db2b1ed69 Mon Sep 17 00:00:00 2001 From: Sunil Thakares Date: Sat, 20 Jun 2026 19:54:29 +0530 Subject: [PATCH] Release v0.5.1: namespace integrity, dashboard parity and QR hardening --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 1233 ++++++++++++++++++++------ docs/RELEASE-NOTES.md | 334 +++++-- docs/TESTING.md | 895 ++++++++----------- docs/UPGRADE.md | 668 ++++++++++---- src/cli/doctor.rs | 70 +- src/cli/restore.rs | 105 ++- src/cli/restore_user.rs | 101 +-- src/cli/shorten.rs | 36 +- src/db/mod.rs | 58 ++ src/db/users.rs | 471 +++++++++- src/templates/analytics.rs | 2 + src/templates/mod.rs | 48 +- src/web/admin.rs | 1208 +++++++++++++++++++++---- src/web/api.rs | 254 +++++- src/web/bulk.rs | 150 +++- src/web/pages.rs | 15 +- src/web/qr.rs | 130 ++- src/web/redirect.rs | 44 +- src/web/routes.rs | 16 + templates/components/qr_preview.html | 9 + templates/health.html | 32 + templates/page_analytics.html | 114 ++- templates/pages.html | 5 +- templates/url_analytics.html | 114 ++- templates/urls.html | 11 +- templates/user_page_analytics.html | 87 -- templates/user_pages.html | 5 +- templates/user_url_analytics.html | 89 -- templates/user_urls.html | 13 +- tests/analytics_parity_tests.rs | 333 +++++++ tests/backup_restore_tests.rs | 41 +- tests/business_workflow_tests.rs | 4 +- tests/concurrency_tests.rs | 4 +- tests/integrity_tests.rs | 11 +- tests/moderation_tests.rs | 3 +- tests/ownership_tests.rs | 226 +++++ tests/qr_endpoint_tests.rs | 300 +++++++ tests/routing_tests.rs | 2 + tests/slug_namespace_tests.rs | 4 +- tests/slug_registry_tests.rs | 242 +++++ tests/slug_transfer_tests.rs | 11 +- tests/soft_delete_tests.rs | 24 +- tests/transaction_tests.rs | 90 ++ 45 files changed, 5851 insertions(+), 1765 deletions(-) create mode 100644 templates/components/qr_preview.html delete mode 100644 templates/user_page_analytics.html delete mode 100644 templates/user_url_analytics.html create mode 100644 tests/analytics_parity_tests.rs create mode 100644 tests/ownership_tests.rs create mode 100644 tests/qr_endpoint_tests.rs create mode 100644 tests/slug_registry_tests.rs create mode 100644 tests/transaction_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 0866b72..bb1f0d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,7 +453,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzod" -version = "0.5.0" +version = "0.5.1" dependencies = [ "argon2", "askama", diff --git a/Cargo.toml b/Cargo.toml index 9484408..6b36cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bzod" -version = "0.5.0" +version = "0.5.1" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 82a3e85..08eb96d 100644 --- a/README.md +++ b/README.md @@ -1,388 +1,1111 @@ # BZOD -> Self-hosted Multi-User URL Management Platform written in Rust. +> Self-hosted Multi-User URL Management, Landing Page and QR Analytics Platform written in Rust. -BZOD combines URL shortening, landing pages, QR codes, analytics, moderation, audit logging, backup & restore workflows, and tenant isolation into a single deployable binary powered entirely by SQLite. +![Rust](https://img.shields.io/badge/Rust-Stable-orange) +![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue) +![License](https://img.shields.io/badge/License-MIT%20%2F%20Apache--2.0-green) +![Version](https://img.shields.io/badge/Version-v0.5.1-purple) -Designed for homelabs, organizations, businesses, educational institutions, and government agencies that require complete ownership of their links, analytics, and operational data. +BZOD combines URL shortening, landing pages, QR code generation, analytics, moderation, audit logging, backup & restore workflows, tenant isolation, and administrative tooling into a single deployable binary powered entirely by SQLite. + +Designed for: + +* Homelabs +* Small Businesses +* Enterprises +* Educational Institutions +* Government Agencies +* Internal IT Platforms +* Self-Hosted Enthusiasts + +BZOD enables complete ownership of: + +* Links +* Analytics +* Users +* QR Codes +* Landing Pages +* Operational Data + +without requiring PostgreSQL, Redis, Elasticsearch, Kubernetes, or external SaaS services. --- -## Why BZOD? +# Why BZOD? Most URL shorteners focus only on redirects and click tracking. -BZOD is designed as a complete self-hosted platform with: +BZOD is designed as a complete self-hosted platform that combines: -* Multi-user architecture -* Tenant isolation -* Landing pages -* QR code generation +* URL Management +* Landing Pages +* QR Codes * Analytics -* Audit logging +* User Management +* Audit Logging * Moderation -* Backup & restore -* Disaster recovery -* Administrative tooling -* REST API -* Web UI -* CLI management +* Backup & Restore +* Disaster Recovery +* Administrative Tooling -All without requiring PostgreSQL, Redis, Elasticsearch, Kubernetes, or external SaaS services. +within a single deployable application. + +The goal is operational simplicity without sacrificing reliability, security, or ownership. --- -## Key Features +# Design Philosophy -### URL Management +BZOD is built around five principles: + +## 1. Single Binary Deployment + +A production deployment should not require: + +* Kubernetes +* Elasticsearch +* Redis +* Multiple microservices + +A single binary should be capable of serving the entire platform. + +--- + +## 2. SQLite First + +SQLite offers: + +* Simplicity +* Reliability +* Easy Backups +* Easy Recovery +* Minimal Operational Overhead + +BZOD embraces SQLite instead of treating it as a development-only database. + +--- + +## 3. Self-Hosted Ownership + +All data belongs to the operator. + +This includes: + +* URLs +* Analytics +* User Accounts +* QR Codes +* Landing Pages +* Audit Logs + +No telemetry is required. + +--- + +## 4. Multi-Tenant Isolation + +Each tenant receives isolated storage and analytics. + +The failure or compromise of one tenant must not affect another tenant. + +--- + +## 5. Recoverability + +A platform that cannot be restored is not production ready. + +BZOD includes: + +* Backup Workflows +* Restore Workflows +* Disaster Recovery Procedures +* Upgrade Validation +* Integrity Checks + +as first-class features. + +--- + +# Key Features + +## URL Management * Short URLs -* Custom slugs -* Bulk operations -* Password-protected links -* Expiring links -* Smart preview pages -* QR code generation - -### Landing Pages - -* Hosted landing pages -* Custom slugs -* Analytics -* QR code support - -### Analytics - -* Visitor tracking -* QR scan analytics -* Browser detection -* Referrer analysis -* Daily and monthly statistics -* CSV export -* JSON export -* Raw visitor logs - -### Multi-User Platform - -* User accounts -* User quotas -* Session management -* API tokens -* Tenant isolation -* Administrative controls - -### Administration - -* User management -* Moderation -* Audit logs -* Session administration -* Quota management -* Backup management -* Health dashboard - -### Operations - -* Backup & restore -* Disaster recovery -* Health monitoring -* Upgrade migrations -* WAL-enabled SQLite databases +* Custom Slugs +* Bulk Operations +* Password-Protected Links +* Expiring Links +* Smart Preview Pages +* QR Code Generation +* QR Downloads (PNG) +* QR Downloads (SVG) --- -## Architecture +## Landing Pages + +* Hosted Landing Pages +* Custom Slugs +* QR Support +* Analytics Integration +* Shareable Campaign Pages + +--- + +## Analytics + +* Visitor Tracking +* QR Scan Analytics +* Browser Detection +* Referrer Analysis +* Daily Statistics +* Monthly Statistics +* CSV Export +* JSON Export +* Raw Visitor Logs + +--- + +## Multi-User Platform + +* User Accounts +* User Quotas +* Session Management +* API Tokens +* Tenant Isolation +* Ownership Validation +* Administrative Controls + +--- + +## Administration + +* User Management +* Moderation +* Audit Logs +* Session Administration +* Quota Management +* Backup Management +* Health Dashboard +* Namespace Diagnostics + +--- + +## Operations + +* Backup & Restore +* Disaster Recovery +* Health Monitoring +* Upgrade Validation +* Migration Framework +* WAL-Enabled SQLite Databases +* Registry Integrity Validation + +--- + +# Global Namespace Integrity + +BZOD enforces a platform-wide slug namespace. + +The following resources cannot share the same slug: + +* Administrator URLs +* Administrator Landing Pages +* User URLs +* User Landing Pages + +Example: + +Valid: ```text - ┌─────────────┐ - │ Browser │ - └──────┬──────┘ - │ - ┌───────▼───────┐ - │ Axum Server │ - └───────┬───────┘ - │ - ┌────────────────────┼────────────────────┐ - │ │ │ - ▼ ▼ ▼ - users.db system.db User Databases - accounts global_slugs content.db - sessions moderation analytics.db - quotas audit logs tenant data - api tokens settings +/company +/docs +/about ``` -### Data Layout +Invalid: + +```text +Admin URL: +/docs + +User Landing Page: +/docs +``` + +Namespace conflicts are automatically detected during: + +* Creation +* Startup +* Restore +* Upgrade +* Validation + +This guarantees predictable routing behavior across the platform. + +--- + +# Global Slug Registry + +BZOD uses a centralized registry to manage all public routes. + +Stored in: + +```text +system.db +``` + +Registry table: + +```text +global_slugs +``` + +Tracks: + +* slug +* owner_user_id +* target_type +* target_id +* status + +The registry acts as the authoritative source of truth for: + +* Redirect Resolution +* Landing Page Routing +* QR Generation +* Ownership Validation +* Namespace Enforcement + +--- + +# Architecture + +```text + Browser + │ + ▼ + ┌──────────────────┐ + │ Axum Server │ + └────────┬─────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ▼ ▼ ▼ + + users.db system.db User Databases + + Accounts Global Slugs content.db + Sessions Moderation analytics.db + API Tokens Audit Events + Quotas Settings +``` + +--- + +# High-Level Request Flow + +```text +Client Request + │ + ▼ +Axum Router + │ + ▼ +Global Slug Registry Lookup + │ + ▼ +Ownership Resolution + │ + ▼ +Tenant Database + │ + ▼ +Response +``` + +--- + +# Data Layout ```text data/ -├── system.db -├── users.db -│ ├── admin/ -│ ├── content.db -│ └── analytics.db +│ ├── users.db +│ ├── system.db +│ ├── admin.db +│ └── admin.db-wal │ └── users/ + ├── 1/ + │ ├── content.db + │ ├── analytics.db + │ └── profile.db + │ ├── 2/ │ ├── content.db - │ └── analytics.db - │ - ├── 3/ - │ ├── content.db - │ └── analytics.db + │ ├── analytics.db + │ └── profile.db │ └── ... ``` -### Core Databases +--- -#### users.db +# Core Databases + +## users.db Stores: * Users -* Password hashes +* Password Hashes * Sessions -* API tokens +* API Tokens * Quotas +* User Status -#### system.db +--- + +## system.db Stores: -* Global slug namespace -* Moderation events -* Audit events -* Reserved slugs +* Global Slug Registry +* Moderation Events +* Audit Events +* Reserved Slugs * Settings +* System Metadata -#### Tenant Databases +--- -Each user receives isolated databases: +## Tenant Databases -##### content.db +Every user receives isolated databases. + +### content.db + +Stores: * URLs -* Landing pages +* Landing Pages * Metadata +* QR Relationships +* Preview Data -##### analytics.db +### analytics.db + +Stores: * Visits -* QR scans +* QR Scans * Referrers -* User agents -* Aggregated statistics +* User Agents +* Aggregated Statistics + +### profile.db + +Stores: + +* User Preferences +* Tenant Metadata +* Account Configuration --- -## Feature Matrix +# Feature Matrix -| Feature | Status | -| ------------------- | ------ | -| URL Shortening | ✅ | -| Custom Slugs | ✅ | -| Landing Pages | ✅ | -| QR Codes | ✅ | -| QR Analytics | ✅ | -| Password Protection | ✅ | -| Link Expiration | ✅ | -| Analytics Dashboard | ✅ | -| CSV Export | ✅ | -| JSON Export | ✅ | -| REST API | ✅ | -| Web UI | ✅ | -| CLI | ✅ | -| Multi-User Support | ✅ | -| User Quotas | ✅ | -| Session Management | ✅ | -| API Tokens | ✅ | -| Moderation | ✅ | -| Audit Logging | ✅ | -| Backup & Restore | ✅ | -| Disaster Recovery | ✅ | -| Health Monitoring | ✅ | -| Upgrade Migrations | ✅ | +| Feature | Status | +| --------------------------- | ------ | +| URL Shortening | ✅ | +| Custom Slugs | ✅ | +| Landing Pages | ✅ | +| QR Codes | ✅ | +| QR Analytics | ✅ | +| PNG Downloads | ✅ | +| SVG Downloads | ✅ | +| Password Protection | ✅ | +| Link Expiration | ✅ | +| Analytics Dashboard | ✅ | +| CSV Export | ✅ | +| JSON Export | ✅ | +| REST API | ✅ | +| Web UI | ✅ | +| CLI | ✅ | +| Multi-User Support | ✅ | +| User Quotas | ✅ | +| Session Management | ✅ | +| API Tokens | ✅ | +| Audit Logging | ✅ | +| Moderation | ✅ | +| Global Namespace Integrity | ✅ | +| Ownership Isolation | ✅ | +| Dashboard Parity | ✅ | +| Backup & Restore | ✅ | +| Disaster Recovery | ✅ | +| Health Monitoring | ✅ | +| Upgrade Validation | ✅ | +| Restore Collision Detection | ✅ | +| Stale Reservation Recovery | ✅ | + +--- +# Screenshots + +## Administrator Dashboard + +Features: + +* Platform Statistics +* User Management +* Health Monitoring +* Moderation +* Audit Events +* Namespace Diagnostics + +```text +screenshots/dashboard.png +``` --- -## Screenshots +## URL Management -### Dashboard +Features: -![Dashboard](screenshots/dashboard.png) +* URL Creation +* QR Preview +* PNG Download +* SVG Download +* Analytics +* Export Functions -### URL Management - -![URL Management](screenshots/short-url-panel.png) - -### Landing Pages - -![Landing Pages](screenshots/landing-page-panel.png) - -### Settings - -![Settings](screenshots/settings.png) - -### Health Dashboard - -![Health Dashboard](screenshots/server-status.png) +```text +screenshots/short-url-panel.png +``` --- -## Installation +## Landing Pages -### Docker +Features: + +* Landing Page Creation +* Slug Management +* QR Support +* Analytics +* Public Publishing + +```text +screenshots/landing-page-panel.png +``` + +--- + +## Settings + +Features: + +* Password Management +* Session Control +* API Tokens +* User Preferences + +```text +screenshots/settings.png +``` + +--- + +## Health Dashboard + +Features: + +* Database Health +* Namespace Validation +* Storage Information +* System Diagnostics + +```text +screenshots/server-status.png +``` + +--- + +# Installation + +BZOD can be deployed using: + +* Docker +* Docker Compose +* Native Binary +* Systemd Service + +Supported Platforms: + +* Linux +* Debian +* Ubuntu +* Arch Linux +* Rocky Linux +* Alma Linux +* Fedora + +--- + +# Docker Deployment + +Build: + +```bash +docker compose build +``` + +Start: ```bash docker compose up -d ``` -### Native +Check status: + +```bash +docker compose ps +``` + +View logs: + +```bash +docker compose logs -f +``` + +Stop: + +```bash +docker compose down +``` + +--- + +# Docker Compose Example + +```yaml +services: + bzod: + build: . + container_name: bzod + restart: unless-stopped + + ports: + - "8654:8654" + + volumes: + - ./data:/app/data + + environment: + - BZOD_BASE_URL=https://bzo.in +``` + +--- + +# Native Installation + +Clone repository: + +```bash +git clone https://github.com/thakares/nx9-url-shortener.git +cd nx9-url-shortener +``` + +Build: ```bash cargo build --release +``` + +Binary: + +```text +target/release/bzod +``` + +Run: + +```bash ./target/release/bzod serve ``` --- -## CLI +# Systemd Service -### Administration +Example: + +```ini +[Unit] +Description=BZOD URL Management Platform +After=network.target + +[Service] +User=bzod +Group=bzod + +WorkingDirectory=/opt/bzod + +ExecStart=/opt/bzod/bzod serve + +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Enable: + +```bash +sudo systemctl enable bzod +sudo systemctl start bzod +``` + +Status: + +```bash +sudo systemctl status bzod +``` + +--- + +# Command Line Interface + +BZOD includes an extensive command-line interface. + +Display help: + +```bash +bzod --help +``` + +Available commands: + +```text +serve +backup +restore +migrate +stats +validate +doctor + +shorten +expand + +create-admin + +create-user +delete-user +disable-user +enable-user +reset-password +list-users + +backup-user +restore-user +``` + +--- + +# Example Commands + +Create administrator: ```bash bzod create-admin +``` + +Create user: + +```bash bzod create-user -bzod delete-user -bzod disable-user -bzod enable-user -bzod reset-password +``` + +List users: + +```bash bzod list-users ``` -### Backup & Recovery +Disable user: + +```bash +bzod disable-user +``` + +Backup system: ```bash bzod backup -bzod restore ``` -### Maintenance +Restore system: + +```bash +bzod restore backup.tar.gz +``` + +Health diagnostics: ```bash bzod doctor -bzod migrate -bzod validate +``` + +Statistics: + +```bash bzod stats ``` ---- - -## Testing - -BZOD v0.5.0 includes a comprehensive automated validation suite. - -### Validation Coverage - -* Unit tests -* Integration tests -* HTTP E2E tests -* Authentication tests -* Migration tests -* Upgrade validation tests -* User isolation tests -* Security tests -* Backup/restore tests -* Disaster recovery tests -* Concurrency tests -* Business workflow tests -* WAL recovery tests - -### Execute +Shorten URL: ```bash -cargo test +bzod shorten https://example.com ``` -### Quality Gates +Expand URL: ```bash -cargo fmt --check -cargo clippy --all-targets -- -D warnings -cargo test -cargo build --release -cargo audit +bzod expand abc123 ``` --- -## Documentation +# REST API -Additional documentation is available in `docs/`. +BZOD provides a RESTful JSON API. -| Document | Description | -| ---------------- | ---------------------------- | -| API.md | REST API reference | -| CHANGELOG.md | Version history | -| COMPARISON.md | Comparison with alternatives | -| DOCKER-Deploy.md | Docker deployment guide | -| RELEASE-NOTES.md | Release information | -| TESTING.md | Validation and testing | +Typical operations: ---- +* Create URLs +* Update URLs +* Delete URLs +* Retrieve Analytics +* Export Data +* Manage Landing Pages -## Release Status +Example: -### v0.5.0 +```http +POST /api/urls +``` -General Availability (GA) +```json +{ + "destination": "https://example.com", + "code": "example" +} +``` -Validation completed: +Response: -* Formatting -* Linting -* Build verification -* Unit tests -* Integration tests -* HTTP E2E tests -* Business workflow tests -* Upgrade validation tests - ---- - -## Roadmap - -Planned future enhancements: - -* Geographic analytics -* SSO integration -* OpenAPI specification -* Multi-organization support -* Advanced analytics dashboards -* Background scheduler UI - ---- - -## License - -Dual licensed under: - -* Apache License 2.0 -* MIT License - -at your option. +```json +{ + "success": true, + "code": "example" +} +``` See: -* LICENSE-APACHE -* LICENSE-MIT +```text +docs/API.md +``` + +for full API documentation. --- -## Author +# Security -Sunil Purushottam Thakare +Security features include: -Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable, self-hosted software. +* Argon2 Password Hashing +* Session Management +* CSRF Protection +* Ownership Validation +* Tenant Isolation +* Namespace Integrity +* Audit Logging + +Users cannot: + +* Access other user resources +* Access other user analytics +* Export other user data +* Modify other user records + +For details see: + +```text +docs/SECURITY.md +``` + +--- + +# Backup & Recovery + +BZOD treats recoverability as a core feature. + +Supported: + +* Full System Backup +* Full System Restore +* Per User Backup +* Per User Restore +* Disaster Recovery +* Upgrade Validation +* Collision Detection + +See: + +```text +docs/BACKUP_RESTORE.md +``` + +--- + +# Testing + +BZOD includes an extensive automated validation suite. + +Validation Categories: + +* Unit Tests +* Integration Tests +* HTTP E2E Tests +* Security Tests +* Business Workflow Tests +* Backup Tests +* Restore Tests +* Disaster Recovery Tests +* Upgrade Validation Tests +* Namespace Integrity Tests +* Ownership Isolation Tests +* Dashboard Parity Tests +* QR Endpoint Tests +* Concurrency Tests +* WAL Recovery Tests + +Run validation: + +```bash +cargo fmt --check + +cargo clippy --all-targets -- -D warnings + +cargo test --all-targets -- --nocapture +``` + +Build production release: + +```bash +cargo build --release +``` + +See: + +```text +docs/TESTING.md +``` + +--- + +# Documentation + +| Document | Description | +| ----------------- | ---------------------------- | +| ADMIN_GUIDE.md | Administrator Operations | +| API.md | REST API Reference | +| ARCHITECTURE.md | System Architecture | +| BACKUP_RESTORE.md | Backup & Recovery | +| CHANGELOG.md | Version History | +| CLI.md | Command Reference | +| COMPARISON.md | Comparison With Alternatives | +| DATABASES.md | Database Architecture | +| DOCKER-Deploy.md | Docker Deployment | +| INSTALL.md | Installation Guide | +| MULTI_USER.md | Multi-Tenant Architecture | +| RELEASE-NOTES.md | Release Notes | +| SECURITY.md | Security Model | +| TESTING.md | Testing Guide | +| UPGRADE.md | Upgrade Procedures | + +--- + +# Project Structure + +```text +src/ +├── analytics/ +├── auth/ +├── charts/ +├── cli/ +├── db/ +├── jobs/ +├── models/ +├── services/ +├── templates/ +├── utils/ +└── web/ + +templates/ +tests/ +docs/ +www/ +``` + +Current codebase: + +```text +~200 files +Rust +SQLite +Axum +Askama +``` + +--- + +# Comparison + +| Feature | BZOD | Traditional URL Shortener | +| ------------------------ | ---- | ------------------------- | +| URL Shortening | ✅ | ✅ | +| Landing Pages | ✅ | ❌ | +| QR Analytics | ✅ | Limited | +| Multi-User Support | ✅ | Limited | +| Audit Trail | ✅ | Rare | +| Backup & Restore | ✅ | Rare | +| Ownership Isolation | ✅ | Rare | +| Namespace Integrity | ✅ | Rare | +| Health Diagnostics | ✅ | Rare | +| Single Binary Deployment | ✅ | Varies | + +For detailed comparisons: + +```text +docs/COMPARISON.md +``` + +--- + +# Release Status + +## v0.5.1 + +Namespace Integrity & Multi-User Hardening Release + +Status: + +```text +Production Ready +``` + +Validated: + +* Formatting +* Static Analysis +* Release Builds +* Namespace Integrity +* Ownership Isolation +* Dashboard Parity +* QR Endpoints +* Routing +* Upgrade Validation +* Backup & Restore +* Disaster Recovery +* Security Validation + +--- + +# Roadmap + +Future areas of exploration: + +* SSO Integration +* OIDC Authentication +* LDAP Integration +* Enhanced API Tokens +* Scheduled Reporting +* Additional Export Formats +* Enhanced Moderation Tools +* Advanced Analytics + +Roadmap priorities remain guided by: + +* Operational Simplicity +* Reliability +* Recoverability +* Self-Hosting + +--- + +# License + +Dual Licensed: + +* MIT License +* Apache License 2.0 + +See: + +```text +LICENSE-MIT +LICENSE-APACHE +``` + +--- + +# Repository + +GitHub: + +```text +https://github.com/thakares/nx9-url-shortener +``` + +Codeberg: + +```text +https://codeberg.org/thakares/nx9-url-shortener +``` + +--- + +# Author + +**Sunil Thakare** + +BZOD is developed as a practical, self-hosted URL management platform focused on simplicity, ownership, and recoverability. + +--- + +# Final Thoughts + +BZOD is not merely a URL shortener. + +It is a self-hosted platform for: + +* URL Management +* Landing Pages +* QR Analytics +* Multi-Tenant Operations +* Administrative Control +* Data Ownership +* Backup & Recovery + +while remaining deployable as a single Rust application backed by SQLite. + +The goal is simple: + +> Own your links. Own your analytics. Own your data. diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 00eee66..4582267 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -1,93 +1,273 @@ -# BZOD v0.5.0 — General Availability +# BZOD v0.5.1 — Namespace Integrity & Platform Hardening -**Release Date:** 2026-06-19 +**Release Date:** 2026-06-20 -**BZOD v0.5.0** is the largest release in project history and marks the transition from a single-user URL shortener into a **production-ready multi-user platform** with tenant isolation, administration tools, analytics, moderation, backups, auditing, and comprehensive validation coverage. +BZOD v0.5.1 focuses on platform integrity, multi-tenant safety, dashboard parity, QR reliability, and upgrade validation. -## Key Highlights - -- **Multi-User Architecture** with strong tenant isolation -- **Global Slug Namespace** with ownership tracking -- **New Administration Portal** for user management and moderation -- **User Self-Service Portal** with dedicated dashboards -- **Enhanced Analytics** with per-user and platform-wide views -- **Comprehensive Backup & Recovery** tooling -- **Upgrade Framework** with automated migration validation -- **90+ Automated Tests** covering multi-user scenarios, upgrades, and disaster recovery +While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the foundations required for safe operation at scale. --- -## Multi-User Architecture +# Highlights -BZOD now supports multiple isolated users on a single deployment. +## Global Slug Registry -**Key capabilities:** -- Per-user content and analytics databases -- User quotas and status management -- User sessions and API tokens -- Strong tenant isolation enforcement +Introduced a hardened global slug registry to guarantee namespace integrity across the entire platform. -Each user's data is strictly separated. +The following resources can no longer share the same slug: -## Administration Portal +* Administrator URLs +* Administrator Landing Pages +* User URLs +* User Landing Pages -New administrative dashboards include: - -**User Management** -- Create, delete, enable, disable users -- Password resets and quota management - -**Moderation Tools** -- Flag, disable, or re-enable content -- Moderation history - -**Session & Audit Management** -- View and revoke sessions -- Full audit viewer - -**Backup Management** -- Create, restore, and download backups - -## User Self-Service Portal - -Standard users now have dedicated dashboards for: -- Profile & password management -- Link and landing page management -- QR code generation -- Personal analytics -- API token management - -## Security & Reliability - -- Improved authentication and authorization -- Enhanced CSRF, SQL injection, and path traversal protection -- SQLite WAL mode with better transaction handling -- Comprehensive backup & recovery with rollback protection - -## Testing & Validation - -**Passed:** -- 90+ automated tests -- Full multi-user validation -- Upgrade path verification -- Backup/Restore + Disaster recovery tests -- Security regression tests -- Concurrency and WAL recovery tests - -## Breaking Changes - -The internal storage model has changed significantly to support multi-user operation. - -**Administrators upgrading from v0.4.x should review:** -- `UPGRADE.md` -- `MULTI_USER.md` -- `BACKUP_RESTORE.md` +Duplicate namespace conflicts are automatically detected and blocked. --- -## Get Started +## Namespace Integrity Validation -**One-command installation:** +New validation routines now verify: -```bash -curl -fsSL https://bzo.in/deploy.sh | sudo bash \ No newline at end of file +* Duplicate slug detection +* Missing ownership records +* Invalid registry entries +* Invalid target types +* Orphaned slug references + +Namespace conflicts now abort upgrades and restores before corruption can occur. + +--- + +## Reservation-Based Slug Allocation + +BZOD now reserves slugs before content creation. + +Creation workflow: + +```text +Quota Check +↓ +Reserve Global Slug +↓ +Create Content +↓ +Activate Slug +↓ +Increment Quota +↓ +Audit Log +``` + +Benefits: + +* Prevents race conditions +* Prevents duplicate creation under concurrency +* Enables safer rollback handling + +--- + +## Stale Reservation Recovery + +Added automatic cleanup of abandoned slug reservations. + +Scenarios covered: + +* Server crash during creation +* Interrupted writes +* Failed transactions + +BZOD now automatically recovers stale reservations during startup. + +--- + +## Dashboard Parity + +Administrator and Standard User dashboards now provide equivalent functionality where appropriate. + +Added parity validation for: + +* URL management +* Landing page management +* Analytics +* QR code previews +* Export functionality + +Differences remain only for administrator-specific operations. + +--- + +## Unified Analytics Templates + +Removed duplicated analytics templates. + +Benefits: + +* Consistent rendering +* Reduced maintenance burden +* Improved reliability + +Administrator and user analytics now share the same rendering logic. + +--- + +## QR Code Improvements + +QR functionality was substantially improved. + +### Added + +* Inline QR previews +* PNG downloads +* SVG downloads +* Shared QR rendering component + +### Fixed + +* Landing page QR generation +* Multi-user QR ownership handling +* QR routing consistency +* Content-type validation + +--- + +## Canonical Landing Page Routing + +Landing page slugs now redirect permanently to canonical page URLs. + +Example: + +```text +/landing-page +``` + +redirects to: + +```text +/p/landing-page +``` + +using: + +```http +301 Moved Permanently +``` + +This improves consistency and SEO behavior. + +--- + +## Ownership Isolation Hardening + +Additional protections ensure: + +* Users cannot access another user's analytics +* Users cannot export another user's data +* Users cannot manage another user's resources + +New ownership validation tests were added. + +--- + +## Backup & Restore Improvements + +Restore operations now validate namespace integrity before importing data. + +Benefits: + +* No silent slug collisions +* No partial restores +* No hidden ownership conflicts + +Restore operations fail safely when conflicts are detected. + +--- + +## Upgrade Validation Enhancements + +Upgrade workflows now verify: + +* Global namespace consistency +* Duplicate slug conflicts +* Registry integrity +* Tenant ownership correctness + +Unsafe upgrades are blocked automatically. + +--- + +## Health & Diagnostics + +The system health subsystem now validates: + +* Global slug registry integrity +* Namespace conflicts +* Ownership consistency +* Stale reservations + +This improves operational visibility and troubleshooting. + +--- + +# Testing & Validation + +BZOD v0.5.1 passed: + +* Formatting validation (`cargo fmt --check`) +* Static analysis (`cargo clippy --all-targets -- -D warnings`) +* Full automated test suite +* Namespace integrity tests +* Ownership isolation tests +* QR endpoint tests +* Dashboard parity tests +* Upgrade validation tests +* Backup & restore tests +* Disaster recovery tests +* Security tests +* Concurrency tests + +All automated tests pass successfully. + +--- + +# Upgrade Notes + +Administrators upgrading from v0.5.0 should review: + +* UPGRADE.md +* MULTI_USER.md +* BACKUP_RESTORE.md +* DATABASES.md +* TESTING.md + +BZOD will automatically validate namespace integrity before completing upgrades. + +Duplicate slugs that previously existed across users or resource types must be resolved before migration can proceed. + +--- + +# Breaking Changes + +## Global Namespace Enforcement + +Slugs are now globally unique across the entire platform. + +Configurations that previously relied on duplicate slugs across users or resource types will be rejected during upgrade. + +This behavior is intentional and protects routing integrity. + +--- + +# Summary + +BZOD v0.5.1 is an integrity-focused release that significantly strengthens: + +* Namespace safety +* Multi-tenant isolation +* Dashboard consistency +* QR reliability +* Restore safety +* Upgrade safety +* Operational diagnostics + +The result is a more predictable, recoverable, and production-ready platform. diff --git a/docs/TESTING.md b/docs/TESTING.md index a1679c0..8f0f0ea 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,34 +1,68 @@ -# TESTING.md +# BZOD Testing & Validation Guide -# BZOD Test Procedures +## Overview -This document describes the official verification procedures for BZOD. +BZOD follows a defense-in-depth validation strategy. -The objective is not merely to confirm that code compiles, but to ensure that the complete platform can be built, deployed, backed up, restored, migrated, and recovered successfully. +A release is considered valid only when: + +* Code quality checks pass +* Automated tests pass +* Upgrade validation passes +* Backup/restore validation passes +* Namespace integrity validation passes +* Multi-user isolation validation passes +* Disaster recovery validation passes + +The objective is not simply to ensure the application starts, but to ensure that it can be safely upgraded, operated, backed up, restored, and recovered. --- -# Philosophy +# Validation Philosophy BZOD prioritizes: -1. Data Integrity -2. Operational Simplicity -3. Recovery Capability -4. Deployment Reproducibility -5. Functional Correctness +1. Namespace Integrity +2. Data Integrity +3. Multi-Tenant Isolation +4. Operational Simplicity +5. Recovery Capability +6. Security +7. Functional Correctness -A passing unit test suite alone is insufficient. +A successful release is not merely one that runs. -A release is considered valid only if backup, restore, migration, and recovery procedures have been verified. +A successful release is one that can be recovered. --- -# Test Categories +# Automated Test Coverage -## 1. Build Verification +Current validation suite includes: -Verify the application compiles successfully. +* Unit Tests +* Integration Tests +* HTTP E2E Tests +* Business Workflow Tests +* Security Tests +* Backup & Restore Tests +* Disaster Recovery Tests +* Migration Tests +* Upgrade Validation Tests +* Namespace Integrity Tests +* Ownership Isolation Tests +* Dashboard Parity Tests +* QR Endpoint Tests +* Concurrency Tests +* WAL Recovery Tests + +The platform currently executes approximately 100+ automated tests. + +--- + +# 1. Build Validation + +Verify successful compilation. ```bash cargo check @@ -36,261 +70,79 @@ cargo build cargo build --release ``` -Expected Result: +Expected: -* No compiler errors -* No panics during startup -* Release binary generated successfully +* No compilation failures +* Release binary generated --- -## 2. Static Analysis +# 2. Formatting Validation ```bash cargo fmt --check -cargo clippy --all-targets ``` -Expected Result: +Expected: -* Formatting passes -* No significant Clippy warnings +* No formatting errors --- -## 3. Unit Tests +# 3. Static Analysis ```bash -cargo test +cargo clippy --all-targets -- -D warnings ``` -Expected Result: +Expected: + +* Zero warnings +* Zero errors + +--- + +# 4. Complete Automated Test Suite + +```bash +cargo test --all-targets -- --nocapture +``` + +Expected: * All tests pass +* No failures * No ignored critical tests --- -## 4. Database Initialization +# 5. Database Initialization Validation -Create a clean environment. - -```bash -rm -rf data - -./bzod stats -``` - -Expected Result: - -* Databases are automatically created -* Migrations applied successfully - -Verify: - -```bash -./bzod doctor -``` - -Expected Result: - -```text -Overall status: HEALTHY -``` - ---- - -## 5. Migration Verification - -Run migrations repeatedly. - -```bash -./bzod migrate -./bzod migrate -./bzod migrate -``` - -Expected Result: - -* No duplicate migrations -* No errors -* Schema remains stable - ---- - -## 6. Administrator Creation - -Create an administrator account. - -```bash -./bzod create-admin -``` - -Expected Result: - -* User created successfully -* Authentication works - -Attempt duplicate creation: - -```bash -./bzod create-admin -``` - -Expected Result: - -* Duplicate username rejected - ---- - -## 7. Backup Verification - -Create backup archive. - -```bash -./bzod backup -``` - -Expected Result: - -* Backup archive generated -* Archive contains all databases - -Verify: - -```bash -tar -tzf backup-*.tar.gz -``` - -Expected Result: - -```text -admin.db -content.db -analytics.db -system.db -``` - ---- - -## 8. Restore Verification - -Create sample data. - -Generate: - -* Administrator -* URL records -* Landing pages -* Analytics records - -Create backup: - -```bash -./bzod backup -``` - -Delete databases: +Create clean environment: ```bash rm -rf data ``` -Restore: - -```bash -./bzod restore --file backup.tar.gz -``` - -Expected Result: - -* Restore completes successfully -* All records preserved - -Verify: - -```bash -./bzod doctor -./bzod stats -``` - -Expected Result: - -```text -Overall status: HEALTHY -``` - -and original record counts preserved. - ---- -## 9. Disaster Recovery Scenario - -1. Create backup -2. Stop container -3. Delete databases -4. Restore from backup -5. Fix permissions -6. Restart container -7. Validate: - - URLs - - Landing pages - - Audit logs - - Settings - - Analytics - - Status page - -Expected Result: -System fully restored without data loss. -## 10. Disaster Recovery Test - -This is the most important test. - -Procedure: - -1. Backup system. -2. Delete entire data directory. -3. Restore backup. -4. Start server. -5. Login to Admin UI. - -Commands: - -```bash -./bzod backup - -rm -rf data - -./bzod restore --file backup.tar.gz - -./bzod serve -``` - -Expected Result: - -* System fully operational -* No manual database repair required - ---- - -## 11. Database Health Verification - Run: ```bash -./bzod doctor +bzod stats ``` -Expected Result: +Expected: -For every database: +* Database hierarchy created +* Migrations applied +* System healthy -```text -Integrity: ok -Foreign keys: enabled -Journal mode: wal +Validate: + +```bash +bzod doctor ``` -Final result: +Expected: ```text Overall status: HEALTHY @@ -298,358 +150,335 @@ Overall status: HEALTHY --- -## 12. SQLite Integrity Checks +# 6. Namespace Integrity Validation -Manual verification. +BZOD maintains a global slug namespace. -```bash -sqlite3 data/admin.db "PRAGMA integrity_check;" -sqlite3 data/content.db "PRAGMA integrity_check;" -sqlite3 data/analytics.db "PRAGMA integrity_check;" -sqlite3 data/system.db "PRAGMA integrity_check;" -``` - -Expected Result: +The following must never coexist: ```text -ok +Admin URL +hello + +User URL +hello + +Landing Page +hello ``` -for all databases. +Validate: + +```bash +bzod doctor +``` + +Expected: + +```text +No namespace conflicts detected +``` + +Duplicate slugs must abort upgrade and restore operations. --- -## 13. Web Interface Verification +# 7. Multi-User Isolation Validation -Start server. +Verify: -```bash -./bzod serve +* User A cannot access User B URLs +* User A cannot access User B Pages +* User A cannot access User B Analytics +* User A cannot export User B analytics + +Expected: + +```http +403 Forbidden +``` + +for all unauthorized access. + +--- + +# 8. Dashboard Parity Validation + +Verify: + +## Administrator URLs + +Contains: + +* Analytics +* QR Preview +* PNG Download +* SVG Download + +## User URLs + +Contains identical functionality. + +Differences allowed: + +* User Management +* Moderation +* Backups +* Health +* Audit +* Quotas + +Everything else must match. + +--- + +# 9. Analytics Validation + +Verify: + +* URL Analytics +* Landing Page Analytics +* CSV Export +* JSON Export +* Date Filters +* Charts +* Referrer Breakdown +* Country Breakdown +* Browser Breakdown +* Device Breakdown + +Expected: + +Administrator and owner views return identical analytics. + +--- + +# 10. QR Validation + +Verify: + +```text +/api/qr/.png +/api/qr/.svg +``` + +Expected: + +```http +200 OK ``` Verify: -* Homepage loads -* Redirects function -* Landing pages render -* Admin login works -* Dashboard loads -* API endpoints respond +```text +Content-Type: image/png +Content-Type: image/svg+xml +``` + +Disabled resources: + +```http +410 Gone +``` + +Missing resources: + +```http +404 Not Found +``` --- -## 14. Docker Verification +# 11. Routing Validation -Build image. +URL resources: + +```text +/ +``` + +must redirect correctly. + +Landing Pages: + +```text +/ +``` + +must redirect permanently to: + +```text +/p/ +``` + +Expected: + +```http +301 Moved Permanently +``` + +and: + +```http +200 OK +``` + +for final landing page render. + +--- + +# 12. Backup Validation + +Create backup: + +```bash +bzod backup +``` + +Expected: + +Archive generated successfully. + +Validate archive contents. + +--- + +# 13. Restore Validation + +Restore backup: + +```bash +bzod restore --file backup.tar.gz +``` + +Expected: + +* Restore succeeds +* All data preserved +* Namespace integrity preserved + +--- + +# 14. Collision Protection Validation + +Attempt restore containing duplicate slugs. + +Expected: + +```text +Restore aborted +Slug conflict detected +``` + +No partial restore. + +--- + +# 15. Upgrade Validation + +Verify upgrade from legacy deployments. + +Expected: + +* User databases migrated +* Analytics preserved +* Links preserved +* Landing pages preserved +* Authentication preserved + +Duplicate slugs must abort upgrade. + +--- + +# 16. Disaster Recovery Validation + +Procedure: + +1. Backup system +2. Stop service +3. Remove data directory +4. Restore backup +5. Start service + +Expected: + +* Full recovery +* No manual repair +* All URLs functional +* All Landing Pages functional +* Analytics preserved + +--- + +# 17. Docker Validation ```bash docker compose build --no-cache -``` - -Start service. - -```bash docker compose up -d ``` Verify: ```bash -docker compose logs -f +docker compose logs ``` -Expected Result: +Expected: ```text -Listening for requests +Server started successfully ``` +Container health: + +```text +healthy +``` + +--- + +# 18. WAL Recovery Validation + Verify: -```bash -./bzod doctor -``` - -inside container. +* SQLite WAL mode enabled +* Recovery after backup succeeds +* No corruption detected --- -## 15. Upgrade Verification - -1. Create backup. -2. Upgrade binary. -3. Run migration. -4. Start service. - -```bash -./bzod backup - -./bzod migrate - -./bzod serve -``` - -Expected Result: - -* Existing data preserved -* No migration failures - ---- -## Analytics Verification - -Verify: - -* URL analytics page loads -* Landing page analytics page loads -* Visitor activity table renders -* Empty visitor tables render correctly -* CSV export downloads successfully -* JSON export downloads successfully -* Date filtering works -* Invalid date filters return HTTP 400 -* Pagination preserves active filters -* Exports respect active filters -# Release Acceptance Criteria - -A release is considered production-ready only if: - -* Build verification passes -* Static analysis passes -* Unit tests pass -* Backup verification passes -* Restore verification passes -* Disaster recovery verification passes -* Doctor reports HEALTHY -* Docker deployment succeeds -* Web UI functions correctly - -Failure of backup, restore, or disaster recovery tests is considered a release blocker. - ---- - -# BZOD v0.5.0 - -## Overview - -BZOD v0.5.0 includes a comprehensive automated validation suite covering functionality, security, migrations, disaster recovery, concurrency, multi-user isolation, and operational workflows. - -The goal is to ensure production upgrades and deployments can be performed safely with minimal risk. - ---- - -# Test Categories - -## Core Unit Tests - -Validates: - -* SQLite configuration -* WAL configuration -* Integrity checks -* Analytics helpers -* QR code generation -* Database initialization - ---- - -## Authentication Tests - -Validates: - -* Session creation -* Session expiration -* API token creation -* API token revocation - -Files: - -* auth_tests.rs -* auth_migration_tests.rs - ---- - -## User Management Tests - -Validates: - -* User creation -* Password reset -* Disable / Enable -* Status transitions -* Reserved usernames - -Files: - -* user_management_tests.rs - ---- - -## Multi-User Isolation Tests - -Validates: - -* Database isolation -* Cross-user access denial -* Tenant separation - -Files: - -* user_isolation_tests.rs - ---- - -## Slug Namespace Tests - -Validates: - -* Global uniqueness -* Reserved slugs -* Slug release behavior -* Slug ownership transfers - -Files: - -* slug_namespace_tests.rs -* slug_transfer_tests.rs - ---- - -## Security Tests - -Validates: - -* CSRF protections -* Session expiration -* Bootstrap credential invalidation -* SQL injection resistance -* Path traversal rejection - -Files: - -* security_tests.rs - ---- - -## Backup & Disaster Recovery Tests - -Validates: - -* Backup generation -* Restore operations -* Backup metadata integrity -* Corrupted backup rejection -* Rollback on restore failures - -Files: - -* backup_restore_tests.rs -* disaster_recovery_tests.rs - ---- - -## Upgrade Validation Tests - -Validates migration from legacy v0.4.0 deployments. - -Checks: - -* Database relocation -* Analytics preservation -* Link preservation -* Credential compatibility -* Redirection integrity - -Files: - -* upgrade_validation_tests.rs - ---- - -## HTTP End-to-End Tests - -Validates: - -* Login -* Logout -* Session cookies -* CSRF protection -* Administrative authorization - -Files: - -* http_e2e_tests.rs - ---- - -## Business Workflow Tests - -Scenario A - -Administrator creates user → User logs in → User creates link → Visitor accesses link → Analytics recorded. - -Scenario B - -Administrator disables user → Sessions invalidated → Login rejected. - -Scenario C - -Slug transfer between users → Redirect preserved → Analytics preserved. - -Files: - -* business_workflow_tests.rs - ---- - -## Concurrency Tests - -Validates: - -* Concurrent slug creation -* Namespace consistency - -Files: - -* concurrency_tests.rs - ---- - -## WAL Recovery Tests - -Validates: - -* SQLite WAL durability -* Recovery after backup operations - -Files: - -* wal_recovery_tests.rs - ---- - -# Running All Tests - -```bash -cargo test --all-targets -- --nocapture -``` - -# Release Validation +# Release Validation Checklist Before every release: ```bash cargo fmt --check + cargo clippy --all-targets -- -D warnings + cargo test --all-targets -- --nocapture + cargo build --release + cargo audit ``` -A release is considered valid only if all steps complete successfully. +Release is approved only if all steps succeed. +--- -# Guiding Principle +# Release Blockers -A successful release is not merely one that starts. +The following are release blockers: -A successful release is one that can be recovered. +* Namespace conflicts +* Backup failure +* Restore failure +* Upgrade failure +* Multi-user isolation failure +* Ownership validation failure +* Security test failure +* Data corruption +* Disaster recovery failure + +A release that cannot be restored is not considered production ready. diff --git a/docs/UPGRADE.md b/docs/UPGRADE.md index 6ed8e86..7866c77 100644 --- a/docs/UPGRADE.md +++ b/docs/UPGRADE.md @@ -1,25 +1,24 @@ # Upgrade Guide -Version: v0.5.0 +Version: v0.5.1 -This document describes the upgrade process from previous BZOD releases to BZOD v0.5.0. +This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1. --- # Overview -BZOD v0.5.0 introduces the largest architectural change in project history: +BZOD v0.5.1 is a platform hardening release focused on: -* Multi-user architecture -* Tenant isolation -* Global slug namespace -* Centralized authentication -* User quotas -* User-specific analytics -* Administrative user management -* Backup and restore framework +* Global namespace integrity +* Multi-tenant safety +* Dashboard parity +* QR reliability +* Upgrade validation +* Restore collision protection +* Ownership isolation -Existing v0.4.x deployments can be upgraded without data loss. +While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the operational and data integrity guarantees required for production deployments. --- @@ -28,80 +27,175 @@ Existing v0.4.x deployments can be upgraded without data loss. Supported: ```text -v0.4.0 → v0.5.0 -v0.4.x → v0.5.0 +v0.5.0 → v0.5.1 +v0.4.x → v0.5.1 +``` + +Recommended: + +```text +v0.4.x → v0.5.0 → v0.5.1 ``` Unsupported: ```text -v0.3.x → v0.5.0 +v0.3.x → v0.5.1 ``` Older installations should first upgrade to v0.4.x. --- +# Major Changes in v0.5.1 + +## Global Namespace Enforcement + +BZOD now enforces a single platform-wide slug namespace. + +The following resources can no longer share the same slug: + +* Administrator URLs +* Administrator Landing Pages +* User URLs +* User Landing Pages + +Example: + +```text +Admin URL: +hello + +User URL: +hello +``` + +Result: + +```text +Upgrade aborted. +Namespace conflict detected. +``` + +--- + +## Global Slug Registry + +BZOD now treats the slug registry as the authoritative source of truth. + +All slugs are registered in: + +```text +system.db +``` + +Table: + +```text +global_slugs +``` + +The registry tracks: + +```text +slug +owner_user_id +target_type +target_id +status +``` + +--- + +## Reservation-Based Slug Allocation + +Slug creation now follows: + +```text +Quota Validation +↓ +Reserve Global Slug +↓ +Create Resource +↓ +Activate Slug +↓ +Update Quotas +↓ +Audit Log +``` + +Benefits: + +* Prevents race conditions +* Prevents duplicate allocations +* Improves rollback safety +* Improves multi-user integrity + +--- + +## Stale Reservation Recovery + +BZOD automatically cleans abandoned reservations created by: + +* Server crashes +* Interrupted requests +* Failed transactions + +Stale reservations are validated and cleaned during startup. + +--- + # Breaking Changes -## Database Layout +## Global Slug Uniqueness -### v0.4.x +Deployments containing duplicate slugs will not upgrade. + +Example: ```text -data/ -├── admin.db -├── content.db -└── analytics.db +User 1: +!nx9-dns-server + +User 3: +!nx9-dns-server ``` -### v0.5.0 +Result: ```text -data/ -├── users.db -├── system.db -└── users/ - └── 1/ - ├── content.db - └── analytics.db +Upgrade aborted. + +Database upgrade aborted due to slug conflicts. ``` +Conflicts must be resolved before migration can continue. + --- -## Authentication +## Restore Collision Protection -Authentication is now centralized. +Restore operations now validate namespace integrity. -Old: +Example: ```text -admin.db +Existing slug: +company + +Backup slug: +company ``` -New: +Result: ```text -users.db +Restore aborted. +Slug conflict detected. ``` -Sessions are managed globally. - ---- - -## Global Slug Namespace - -Slugs are now unique platform-wide. - -Examples: - -```text -/example -/company -/docs -``` - -cannot exist twice. +No partial restore occurs. --- @@ -109,22 +203,23 @@ cannot exist twice. Before upgrading: -* Verify current version -* Stop active traffic * Create backup * Verify backup integrity +* Stop active traffic +* Run diagnostics +* Resolve namespace conflicts --- -## Step 1: Create Backup +# Step 1: Create Backup -CLI: +Full backup: ```bash bzod backup ``` -or manually archive: +Manual backup: ```bash tar czf bzod-backup.tar.gz data/ @@ -132,9 +227,18 @@ tar czf bzod-backup.tar.gz data/ --- -## Step 2: Verify Backup +# Step 2: Verify Backup -Confirm archive contains: +Verify archive contents: + +```text +users.db +system.db + +users/ +``` + +If upgrading from legacy versions: ```text admin.db @@ -142,9 +246,35 @@ content.db analytics.db ``` +should also be present. + --- -## Step 3: Stop Service +# Step 3: Run Diagnostics + +Execute: + +```bash +bzod doctor +``` + +Expected: + +```text +Overall Status: HEALTHY +``` + +Verify: + +```text +No namespace conflicts detected +No ownership violations detected +No registry corruption detected +``` + +--- + +# Step 4: Stop Service Systemd: @@ -162,15 +292,15 @@ docker compose down # Upgrade Procedure -## Replace Binary +## Install New Version -Install new release: +Build: ```bash cargo build --release ``` -or download release binary. +Or install official release binary. --- @@ -180,93 +310,136 @@ or download release binary. bzod serve ``` -On first startup BZOD automatically: +or: -1. Detects legacy databases. -2. Creates users.db. -3. Creates system.db. -4. Creates administrator tenant. -5. Moves content.db. -6. Moves analytics.db. -7. Creates global slug registry. -8. Runs migrations. - ---- - -# Automatic Migration - -Migration performs: - -## Administrator Creation - -Legacy administrator becomes: - -```text -User ID: 1 -Type: admin +```bash +docker compose up -d ``` --- -## Content Migration +# Automatic Upgrade Actions -All URLs migrate into: +During startup BZOD automatically performs: + +1. Database migration checks +2. Namespace integrity validation +3. Registry validation +4. Stale reservation cleanup +5. Global slug verification +6. Schema migration execution + +--- + +# Namespace Validation + +BZOD scans: ```text -users/1/content.db +legacy databases +administrator databases +tenant databases +``` + +for duplicate slugs. + +Example: + +```text +Owner 1: +hello + +Owner 3: +hello +``` + +Result: + +```text +Namespace conflict detected. +Upgrade aborted. ``` --- -## Analytics Migration +# Registry Validation -All analytics migrate into: +BZOD validates: + +* Duplicate slug entries +* Missing owners +* Missing targets +* Invalid target types +* Invalid status values + +Allowed target types: ```text -users/1/analytics.db +url +page ``` ---- - -## Global Slug Registration - -All existing slugs are inserted into: +Allowed statuses: ```text -system.db.global_slugs +reserving +active +disabled ``` --- # Post-Upgrade Validation -## Login +Run: -Verify: +```bash +bzod doctor +``` + +Expected: ```text -Admin login succeeds +Namespace Integrity: PASS +Registry Integrity: PASS +Ownership Integrity: PASS +Database Integrity: PASS ``` --- -## URLs +# Login Validation Verify: ```text -Short URLs redirect +Administrator login succeeds +User login succeeds ``` -Example: +--- + +# URL Validation + +Verify: ```text https://example.com/abc123 ``` +redirects correctly. + +Expected: + +```http +302 Found +``` + +or configured redirect behavior. + --- -## Landing Pages +# Landing Page Validation Verify: @@ -274,90 +447,166 @@ Verify: https://example.com/p/demo ``` -renders correctly. - ---- - -## Analytics - -Verify: - -* Visits visible -* Reports load -* Charts render - ---- - -## User Management +renders successfully. Verify: ```text -Admin → Users +https://example.com/demo ``` -loads correctly. +redirects permanently: + +```http +301 Moved Permanently +``` + +to: + +```text +/p/demo +``` --- -# Upgrade Validation Tests +# QR Validation -BZOD v0.5.0 includes automated migration tests. +Verify: -Validated: +```text +/api/qr/demo.png +/api/qr/demo.svg +``` -* Legacy admin migration -* Legacy content migration -* Legacy analytics migration -* Slug registration -* Redirect preservation -* Analytics preservation +Expected: -Test suite: +```http +200 OK +``` + +Content types: + +```text +image/png +image/svg+xml +``` + +Disabled resources: + +```http +410 Gone +``` + +Missing resources: + +```http +404 Not Found +``` + +--- + +# Dashboard Validation + +Verify Administrator Dashboards: + +* URLs +* Landing Pages +* Analytics +* QR Preview +* PNG Download +* SVG Download + +Verify Standard User Dashboards: + +* URLs +* Landing Pages +* Analytics +* QR Preview +* PNG Download +* SVG Download + +Both should provide equivalent functionality except for administrator-only operations. + +--- + +# Ownership Isolation Validation + +Verify: + +```text +User A +``` + +cannot access: + +```text +User B Analytics +User B URLs +User B Landing Pages +User B Exports +``` + +Expected: + +```http +403 Forbidden +``` + +--- + +# Backup & Restore Validation + +Create backup: ```bash -cargo test --test upgrade_validation_tests +bzod backup ``` +Restore backup: + +```bash +bzod restore backup.tar.gz +``` + +Expected: + +* No namespace conflicts +* No ownership conflicts +* No partial restores + --- # Rollback Procedure If upgrade validation fails: -## Stop Server +Stop service: ```bash sudo systemctl stop bzod ``` -or +or: ```bash docker compose down ``` ---- - -## Restore Backup +Restore backup: ```bash -bzod restore backup.zip +bzod restore backup.tar.gz ``` or restore archived data directory. ---- - -## Reinstall Previous Release - -Deploy previous v0.4.x binary. +Reinstall previous release. --- # Docker Upgrade -Pull new image: +Pull image: ```bash docker compose pull @@ -369,13 +618,19 @@ Restart: docker compose up -d ``` -Monitor logs: +Monitor: ```bash docker compose logs -f ``` -Verify migrations complete successfully. +Expected: + +```text +Namespace validation passed +Registry validation passed +Server started successfully +``` --- @@ -399,75 +654,142 @@ Verify: sudo systemctl status bzod ``` +Expected: + +```text +active (running) +``` + +--- + +# Automated Upgrade Validation + +Execute: + +```bash +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test --all-targets -- --nocapture +``` + +Particularly validate: + +```text +upgrade_validation_tests +backup_restore_tests +slug_registry_tests +ownership_tests +analytics_parity_tests +transaction_tests +``` + --- # Recommended Upgrade Workflow ```text -1. Create backup -2. Stop service -3. Install v0.5.0 -4. Start service -5. Run migrations -6. Validate login -7. Validate URLs -8. Validate analytics -9. Validate admin dashboard -10. Return to production +1. Create Backup +2. Verify Backup +3. Run bzod doctor +4. Resolve Namespace Conflicts +5. Stop Service +6. Install v0.5.1 +7. Start Service +8. Validate Registry +9. Validate URLs +10. Validate Landing Pages +11. Validate QR Endpoints +12. Validate Dashboards +13. Validate Ownership Isolation +14. Return To Production ``` --- # Troubleshooting -## Login Fails +## Upgrade Aborted Due To Slug Conflicts -Check: +Example: ```text -users.db +Slug '!nx9-dns-server' +is defined in multiple content databases +by owners [1,3] ``` -Verify administrator account exists. +Cause: + +```text +Duplicate slug detected. +``` + +Resolution: + +```text +Rename or remove conflicting resources. +Restart upgrade. +``` --- -## URLs Missing +## QR Codes Return 404 Verify: ```text -users/1/content.db +global_slugs ``` -contains migrated records. +contains the slug. + +Verify slug status: + +```text +active +``` --- -## Analytics Missing +## Landing Page Redirect Fails Verify: ```text -users/1/analytics.db +target_type = page ``` -contains visit data. +in: + +```text +global_slugs +``` --- -## Slug Resolution Fails +## Ownership Errors -Verify: +Run: -```sql -SELECT * FROM global_slugs; +```bash +bzod doctor ``` -returns expected entries. +Verify ownership integrity passes. --- # Upgrade Status -BZOD v0.5.0 upgrade path has been validated through automated migration and integration testing and is considered production-ready for upgrades from v0.4.x deployments. +BZOD v0.5.1 upgrade path has been validated through: + +* Migration Tests +* Upgrade Validation Tests +* Namespace Integrity Tests +* Ownership Isolation Tests +* Backup & Restore Tests +* Dashboard Parity Tests +* QR Endpoint Tests +* Routing Tests + +The v0.5.1 upgrade path is considered production-ready. diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 2ea598a..32d6341 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -22,11 +22,23 @@ pub async fn run( println!("Data directory: {:?}", config.data_dir); println!(); - let databases = ["admin", "content", "analytics", "system"]; let mut all_healthy = true; - for db_name in &databases { - let db_path = config.data_dir.join(format!("{}.db", db_name)); + // Define target databases in the new layout + let admin_dir = config.data_dir.join("admin"); + let dbs = vec![ + ("admin", admin_dir.join("admin.db")), + ("system", admin_dir.join("system.db")), + ("users", admin_dir.join("users.db")), + ("legacy content", config.data_dir.join("content.db")), + ("legacy analytics", config.data_dir.join("analytics.db")), + ]; + + for (db_name, db_path) in dbs { + // Skip legacy databases if they don't exist + if db_name.starts_with("legacy") && !db_path.exists() { + continue; + } if !db_path.exists() { println!("Database: {}", db_name); @@ -75,6 +87,58 @@ pub async fn run( println!(); } + // Global Slug Registry Integrity Check + println!("Global Slug Registry Integrity Check"); + println!("===================================="); + let system_db_path = admin_dir.join("system.db"); + let users_db_path = admin_dir.join("users.db"); + + if system_db_path.exists() && users_db_path.exists() { + match ( + Connection::open(&system_db_path), + Connection::open(&users_db_path), + ) { + (Ok(sys_conn), Ok(usr_conn)) => { + match crate::db::users::verify_global_slug_registry_integrity( + &sys_conn, + &usr_conn, + &config.data_dir, + ) { + Ok((errors, warnings)) => { + if errors.is_empty() && warnings.is_empty() { + println!(" Status: HEALTHY (no issues found)"); + } else { + if !errors.is_empty() { + println!(" Errors (Action Required):"); + for err in &errors { + println!(" - {}", err); + } + all_healthy = false; + } + if !warnings.is_empty() { + println!(" Warnings (Attention Needed):"); + for warn in &warnings { + println!(" - {}", warn); + } + } + } + } + Err(e) => { + println!(" Status: ERROR running integrity check: {}", e); + all_healthy = false; + } + } + } + _ => { + println!(" Status: ERROR opening system.db or users.db for integrity check"); + all_healthy = false; + } + } + } else { + println!(" Status: SKIPPED (system.db/users.db not found)"); + } + println!(); + println!("--------------------"); if all_healthy { println!("Overall status: HEALTHY"); diff --git a/src/cli/restore.rs b/src/cli/restore.rs index 5997cc2..fccce5d 100644 --- a/src/cli/restore.rs +++ b/src/cli/restore.rs @@ -15,34 +15,95 @@ pub fn perform_restore( let tar_gz = GzDecoder::new(f); let mut archive = Archive::new(tar_gz); - // 2. Validate that the archive contains the expected BZOD database files - let mut has_admin = false; - let mut has_content = false; - let mut has_analytics = false; - let mut has_system = false; + // 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)?; - for entry_res in archive.entries()? { - let entry = entry_res?; - let path = entry.path()?; - let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - match file_name { - "admin.db" => has_admin = true, - "content.db" => has_content = true, - "analytics.db" => has_analytics = true, - "system.db" => has_system = true, - _ => {} + if let Err(e) = archive.unpack(&temp_dir) { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(e.into()); + } + + // 3. Run validation on temp_dir + let mut temp_config = Config::load(); + temp_config.data_dir = temp_dir.clone(); + + // Namespace audit + match crate::db::users::audit_slug_namespace(&temp_config) { + Ok(report) => { + if !report.duplicates.is_empty() { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err( + format!("Slug conflicts detected in backup: {:?}", report.duplicates).into(), + ); + } + } + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!("Failed to audit slug namespace in backup: {}", e).into()); } } - if !has_admin || !has_content || !has_analytics || !has_system { - return Err("Archive is missing one or more required database files (admin.db, content.db, analytics.db, system.db)".into()); + // 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") + }; + + if system_db_path.exists() && users_db_path.exists() { + let system_conn = rusqlite::Connection::open(&system_db_path)?; + let users_conn = rusqlite::Connection::open(&users_db_path)?; + match crate::db::users::verify_global_slug_registry_integrity( + &system_conn, + &users_conn, + &temp_dir, + ) { + Ok((errors, _warnings)) => { + if !errors.is_empty() { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!("Registry integrity errors in backup: {:?}", errors).into()); + } + } + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!("Failed to verify registry integrity in backup: {}", e).into()); + } + } } - // 3. Unpack archive to data_dir - let f2 = File::open(file_path)?; - let tar_gz2 = GzDecoder::new(f2); - let mut archive2 = Archive::new(tar_gz2); - archive2.unpack(data_dir)?; + // 4. If validation succeeds, copy temp_dir contents to data_dir + 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<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?; + } else { + std::fs::copy(entry.path(), dst.join(entry.file_name()))?; + } + } + Ok(()) + } + + if let Err(e) = copy_dir_all(&temp_dir, data_dir) { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!("Failed to copy restored files: {}", e).into()); + } + + let _ = std::fs::remove_dir_all(&temp_dir); Ok(()) } diff --git a/src/cli/restore_user.rs b/src/cli/restore_user.rs index e7c1d81..4e60e4c 100644 --- a/src/cli/restore_user.rs +++ b/src/cli/restore_user.rs @@ -1,7 +1,5 @@ use crate::config::Config; use crate::db::Db; -use chrono::Utc; -use rusqlite::OptionalExtension; use std::fs::File; use std::path::PathBuf; use tar::Archive; @@ -158,103 +156,18 @@ pub async fn run( } } - // 4. Register slugs in global_slugs - let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?; + // 4. Register slugs in global_slugs using the shared helper { - let mut system_conn = db.system.lock().unwrap(); - let tx = system_conn.transaction()?; - - // Delete any existing global slugs owned by this user - tx.execute( - "DELETE FROM global_slugs WHERE owner_user_id = ?1;", - [target_user_id], + let system_conn = db.system.lock().unwrap(); + crate::db::users::register_restored_user_slugs( + &system_conn, + target_user_id, + &dest_dir.join("content.db"), )?; - - // Register URLs - { - let mut stmt = - restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - let slug: String = row.get(0)?; - let target_id: String = row.get(1)?; - let created_at: String = row.get(2)?; - let status: String = row.get(3)?; - let now = Utc::now().to_rfc3339(); - - let existing_owner: Option = tx - .query_row( - "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", - [&slug], - |r| r.get(0), - ) - .optional()?; - - if let Some(owner) = existing_owner { - if owner != target_user_id { - error!( - "Conflict: Slug '{}' is already owned by user ID {}. Skipping.", - slug, owner - ); - continue; - } - } - - tx.execute( - "INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) - VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);", - rusqlite::params![slug, target_user_id, target_id, created_at, now, status], - )?; - } - } - - // Register Landing Pages - { - let mut stmt = restored_content_conn - .prepare("SELECT code, id, created_at, state FROM landing_pages;")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - let slug: String = row.get(0)?; - let target_id: String = row.get(1)?; - let created_at: String = row.get(2)?; - let state: String = row.get(3)?; - let now = Utc::now().to_rfc3339(); - let status = if state == "published" { - "active" - } else { - "disabled" - }; - - let existing_owner: Option = tx - .query_row( - "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", - [&slug], - |r| r.get(0), - ) - .optional()?; - - if let Some(owner) = existing_owner { - if owner != target_user_id { - error!( - "Conflict: Slug '{}' is already owned by user ID {}. Skipping.", - slug, owner - ); - continue; - } - } - - tx.execute( - "INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) - VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);", - rusqlite::params![slug, target_user_id, target_id, created_at, now, status], - )?; - } - } - - tx.commit()?; } // 5. Reconcile quotas for restored user + let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?; crate::db::users::reconcile_user_quotas( &db.users.lock().unwrap(), target_user_id, diff --git a/src/cli/shorten.rs b/src/cli/shorten.rs index ee6e073..82a553c 100644 --- a/src/cli/shorten.rs +++ b/src/cli/shorten.rs @@ -33,7 +33,16 @@ pub async fn run( None => crate::utils::random::generate_token(3), }; - // 3. Persist URL + // 3. Register slug in system.db with status 'reserving' and check availability + { + let system_conn = db.system.lock().unwrap(); + if !crate::db::users::is_slug_available(&system_conn, &code)? { + return Err("Short code/slug already exists".into()); + } + crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?; + } + + // 4. Persist URL let conn = db.content.lock().unwrap(); let res = crate::db::content::create_url_extended( &conn, @@ -48,7 +57,21 @@ pub async fn run( ); match res { - Ok(_) => { + Ok(url) => { + // Activate slug in system.db + { + let system_conn = db.system.lock().unwrap(); + 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], + )?; + } + // Increment quota for user ID 1 + { + let users_conn = db.users.lock().unwrap(); + crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?; + } + let proto = if config.cookie_secure { "https" } else { @@ -63,11 +86,10 @@ pub async fn run( println!("{}/{}", base_url, code); Ok(()) } - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - Err("Short code/slug already exists".into()) + Err(e) => { + let system_conn = db.system.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, 1); + Err(e.into()) } - Err(e) => Err(e.into()), } } diff --git a/src/db/mod.rs b/src/db/mod.rs index f39b24b..81a6331 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -68,6 +68,26 @@ impl Db { } } + // Pre-migration safety net: audit slug namespace for duplicates / format errors + match crate::db::users::audit_slug_namespace(config) { + Ok(report) => { + if !report.duplicates.is_empty() { + tracing::error!( + "Namespace conflicts detected before database migration: {:?}", + report.duplicates + ); + return Err(format!( + "Database upgrade aborted due to slug conflicts: {:?}", + report.duplicates + ) + .into()); + } + } + Err(e) => { + tracing::warn!("Failed to audit slug namespace before migration: {}", e); + } + } + let admin_path = admin_dir.join("admin.db"); let system_path = admin_dir.join("system.db"); let users_db_path = admin_dir.join("users.db"); @@ -317,6 +337,44 @@ impl Db { let _ = db.reconcile_global_slugs(config); + // Post-init: Clean up stale reservations + { + let system_conn = db.system.lock().unwrap(); + match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) { + Ok(count) => { + if count > 0 { + tracing::info!("Cleaned up {} stale reserving slugs", count); + } + } + Err(e) => { + tracing::error!("Failed to clean up stale reservations: {}", e); + } + } + } + + // Post-init: Verify global registry integrity + { + let system_conn = db.system.lock().unwrap(); + let users_conn = db.users.lock().unwrap(); + match crate::db::users::verify_global_slug_registry_integrity( + &system_conn, + &users_conn, + &config.data_dir, + ) { + Ok((errors, warnings)) => { + for err in errors { + tracing::error!("Global registry integrity error: {}", err); + } + for warn in warnings { + tracing::warn!("Global registry integrity warning: {}", warn); + } + } + Err(e) => { + tracing::error!("Failed to verify global registry integrity: {}", e); + } + } + } + Ok(db) } diff --git a/src/db/users.rs b/src/db/users.rs index 920f6e8..0b1cefc 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -303,6 +303,19 @@ pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result rusqlite::Result { + if let Some(quotas) = get_user_quotas(conn, user_id)? { + match field { + "urls" => Ok(quotas.current_urls < quotas.max_urls), + "landings" => Ok(quotas.current_landings < quotas.max_landings), + "api_tokens" => Ok(quotas.current_api_tokens < quotas.max_api_tokens), + _ => Ok(false), + } + } else { + Ok(false) + } +} + pub fn update_user_quotas( conn: &Connection, user_id: i64, @@ -458,12 +471,13 @@ pub fn register_global_slug( owner_user_id: i64, target_type: &str, target_id: &str, + status: &str, ) -> rusqlite::Result<()> { let now = Utc::now().to_rfc3339(); system_conn.execute( "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", - rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"], + rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, status], )?; // Insert history @@ -501,7 +515,7 @@ pub fn soft_delete_global_slug( ) -> rusqlite::Result<()> { let now = Utc::now().to_rfc3339(); system_conn.execute( - "UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;", + "UPDATE global_slugs SET status = 'disabled', deleted_at = ?1 WHERE slug = ?2;", rusqlite::params![now, slug], )?; @@ -515,6 +529,459 @@ pub fn soft_delete_global_slug( Ok(()) } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct SlugAuditReport { + pub duplicates: Vec, + pub invalid_entries: Vec, + pub warnings: Vec, +} + +pub fn audit_slug_namespace( + config: &crate::config::Config, +) -> Result> { + use std::collections::HashMap; + let mut duplicates = Vec::new(); + let mut invalid_entries = Vec::new(); + let warnings = Vec::new(); + + let mut slug_owners: HashMap> = HashMap::new(); + + // 1. Scan legacy content.db if it exists + let legacy_content_path = config.data_dir.join("content.db"); + if legacy_content_path.exists() { + if let Ok(conn) = Connection::open(&legacy_content_path) { + // URLs + if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") { + if let Ok(mut rows) = stmt.query([]) { + while let Some(row) = rows.next().unwrap_or(None) { + if let Ok(code) = row.get::<_, String>(0) { + slug_owners.entry(code).or_default().push(1); // 1 = legacy admin + } + } + } + } + // Landing Pages + if let Ok(mut stmt) = conn.prepare("SELECT code FROM landing_pages;") { + if let Ok(mut rows) = stmt.query([]) { + while let Some(row) = rows.next().unwrap_or(None) { + if let Ok(code) = row.get::<_, String>(0) { + slug_owners.entry(code).or_default().push(1); + } + } + } + } + } + } + + // 2. Scan all tenant databases in data_dir/users//content.db + let users_dir = config.data_dir.join("users"); + if users_dir.exists() { + for entry in std::fs::read_dir(users_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) { + if let Ok(user_id) = name_str.parse::() { + let content_db_path = path.join("content.db"); + if content_db_path.exists() { + if let Ok(conn) = Connection::open(&content_db_path) { + // URLs + if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") { + if let Ok(mut rows) = stmt.query([]) { + while let Some(row) = rows.next().unwrap_or(None) { + if let Ok(code) = row.get::<_, String>(0) { + slug_owners.entry(code).or_default().push(user_id); + } + } + } + } + // Landing pages + if let Ok(mut stmt) = + conn.prepare("SELECT code FROM landing_pages;") + { + if let Ok(mut rows) = stmt.query([]) { + while let Some(row) = rows.next().unwrap_or(None) { + if let Ok(code) = row.get::<_, String>(0) { + slug_owners.entry(code).or_default().push(user_id); + } + } + } + } + } + } + } + } + } + } + } + + // 3. Populate report + for (slug, owners) in slug_owners { + if owners.len() > 1 { + duplicates.push(format!( + "Slug '{}' is defined in multiple content databases by owners {:?}", + slug, owners + )); + } + // Validate slug format + let valid_url = crate::utils::validation::validate_redirect_code(&slug); + let valid_page = crate::utils::validation::validate_page_code(&slug); + if !valid_url && !valid_page { + invalid_entries.push(format!("Slug '{}' is format-invalid", slug)); + } + } + + Ok(SlugAuditReport { + duplicates, + invalid_entries, + warnings, + }) +} + +pub fn cleanup_stale_reservations( + system_conn: &Connection, + data_dir: &std::path::Path, +) -> Result> { + use chrono::{DateTime, Utc}; + let mut cleaned_count = 0; + + let mut stmt = system_conn.prepare( + "SELECT slug, owner_user_id, target_type, created_at FROM global_slugs WHERE status = 'reserving';" + )?; + let mut rows = stmt.query([])?; + let mut stale_slugs = Vec::new(); + + while let Some(row) = rows.next()? { + let slug: String = row.get(0)?; + let owner_user_id: i64 = row.get(1)?; + let target_type: String = row.get(2)?; + let created_at_str: String = row.get(3)?; + + if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) { + let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc)); + if age > chrono::Duration::minutes(15) { + // Check if target record exists by looking up code = slug in owner's content.db + let content_db_path = if owner_user_id == 1 { + let p1 = data_dir.join("users").join("1").join("content.db"); + if p1.exists() { + p1 + } else { + data_dir.join("content.db") + } + } else { + data_dir + .join("users") + .join(owner_user_id.to_string()) + .join("content.db") + }; + + let mut target_exists = false; + if content_db_path.exists() { + if let Ok(conn) = Connection::open(&content_db_path) { + if target_type == "url" { + target_exists = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM urls WHERE code = ?1);", + [&slug], + |r| r.get(0), + ) + .unwrap_or(false); + } else if target_type == "page" { + target_exists = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM landing_pages WHERE code = ?1);", + [&slug], + |r| r.get(0), + ) + .unwrap_or(false); + } + } + } + + if !target_exists { + stale_slugs.push((slug, owner_user_id)); + } + } + } + } + + drop(rows); + drop(stmt); + + for (slug, owner_user_id) in stale_slugs { + system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug])?; + let now = Utc::now().to_rfc3339(); + system_conn.execute( + "INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp) + VALUES (?1, ?2, NULL, 'released', ?3);", + rusqlite::params![slug, owner_user_id, now], + )?; + cleaned_count += 1; + } + + Ok(cleaned_count) +} + +pub fn verify_global_slug_registry_integrity( + system_conn: &Connection, + users_conn: &Connection, + data_dir: &std::path::Path, +) -> Result<(Vec, Vec), Box> { + use chrono::{DateTime, Utc}; + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + // 1. Check duplicate slugs + let total_count: i64 = + system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?; + let distinct_count: i64 = + system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| { + r.get(0) + })?; + if total_count != distinct_count { + errors.push(format!( + "Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})", + total_count, distinct_count + )); + } + + // 2. Scan all global slugs + let mut stmt = system_conn.prepare( + "SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;", + )?; + let mut rows = stmt.query([])?; + + while let Some(row) = rows.next()? { + let slug: String = row.get(0)?; + let owner_user_id: i64 = row.get(1)?; + let target_type: String = row.get(2)?; + let target_id: String = row.get(3)?; + let created_at_str: String = row.get(4)?; + let status: String = row.get(5)?; + + // Target type check + if target_type != "url" && target_type != "page" { + errors.push(format!( + "Slug '{}' has invalid target_type '{}'", + slug, target_type + )); + } + + // Status check + if status != "active" && status != "disabled" && status != "reserving" { + errors.push(format!("Slug '{}' has invalid status '{}'", slug, status)); + } + + // Check owner + let owner_exists: bool = users_conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);", + [owner_user_id], + |r| r.get(0), + ) + .unwrap_or(false); + + if !owner_exists { + errors.push(format!( + "Slug '{}' references missing owner user ID {}", + slug, owner_user_id + )); + continue; + } + + // Stale warning check + if status == "reserving" { + if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) { + let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc)); + if age > chrono::Duration::minutes(15) { + warnings.push(format!( + "Reserving slug '{}' has been stale for over 15 minutes", + slug + )); + } + } + } + + // Check target record exists for active / disabled (and reserving with target_id) + if status == "active" + || status == "disabled" + || (status == "reserving" && !target_id.is_empty()) + { + let content_db_path = if owner_user_id == 1 { + let p1 = data_dir.join("users").join("1").join("content.db"); + if p1.exists() { + p1 + } else { + data_dir.join("content.db") + } + } else { + data_dir + .join("users") + .join(owner_user_id.to_string()) + .join("content.db") + }; + + if !content_db_path.exists() { + errors.push(format!( + "Slug '{}' owner content database does not exist at {:?}", + slug, content_db_path + )); + } else { + match Connection::open(&content_db_path) { + Ok(conn) => { + let exists = if target_type == "url" { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);", + [&target_id], + |r| r.get(0), + ) + .unwrap_or(false) + } else if target_type == "page" { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);", + [&target_id], + |r| r.get(0), + ) + .unwrap_or(false) + } else { + false + }; + + if !exists { + errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id)); + } + } + Err(e) => { + errors.push(format!( + "Slug '{}' owner content database could not be opened: {}", + slug, e + )); + } + } + } + } + } + + Ok((errors, warnings)) +} + +pub fn register_restored_user_slugs( + system_conn: &Connection, + target_user_id: i64, + restored_content_db_path: &std::path::Path, +) -> Result<(), Box> { + let restored_content_conn = Connection::open(restored_content_db_path)?; + + let mut urls = Vec::new(); + let mut landing_pages = Vec::new(); + + // 1. Read URLs + { + let mut stmt = + restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let code: String = row.get(0)?; + let id: String = row.get(1)?; + let created_at: String = row.get(2)?; + let status: String = row.get(3)?; + urls.push((code, id, created_at, status)); + } + } + + // 2. Read Landing Pages + { + let mut stmt = restored_content_conn + .prepare("SELECT code, id, created_at, state FROM landing_pages;")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let code: String = row.get(0)?; + let id: String = row.get(1)?; + let created_at: String = row.get(2)?; + let state: String = row.get(3)?; + landing_pages.push((code, id, created_at, state)); + } + } + + // 3. Check for collisions across all URLs and landing pages + let mut conflicting_slugs = Vec::new(); + for (slug, _, _, _) in &urls { + let existing_owner: Option = system_conn + .query_row( + "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", + [slug], + |r| r.get(0), + ) + .optional()?; + + if let Some(owner) = existing_owner { + if owner != target_user_id { + conflicting_slugs.push(slug.clone()); + } + } + } + + for (slug, _, _, _) in &landing_pages { + let existing_owner: Option = system_conn + .query_row( + "SELECT owner_user_id FROM global_slugs WHERE slug = ?1;", + [slug], + |r| r.get(0), + ) + .optional()?; + + if let Some(owner) = existing_owner { + if owner != target_user_id { + conflicting_slugs.push(slug.clone()); + } + } + } + + if !conflicting_slugs.is_empty() { + return Err(format!( + "Restore failed. Conflicting slugs: {}", + conflicting_slugs.join(", ") + ) + .into()); + } + + // 4. Perform registration + system_conn.execute( + "DELETE FROM global_slugs WHERE owner_user_id = ?1;", + [target_user_id], + )?; + + for (slug, target_id, created_at, status) in urls { + let now = Utc::now().to_rfc3339(); + let global_status = if status == "dead" { + "disabled" + } else { + "active" + }; + system_conn.execute( + "INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);", + rusqlite::params![slug, target_user_id, target_id, created_at, now, global_status], + )?; + } + + for (slug, target_id, created_at, state) in landing_pages { + let now = Utc::now().to_rfc3339(); + let status = if state == "published" { + "active" + } else { + "disabled" + }; + system_conn.execute( + "INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) + VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);", + rusqlite::params![slug, target_user_id, target_id, created_at, now, status], + )?; + } + + Ok(()) +} + pub fn reconcile_user_quotas( users_conn: &Connection, user_id: i64, diff --git a/src/templates/analytics.rs b/src/templates/analytics.rs index ed8a900..0d82297 100644 --- a/src/templates/analytics.rs +++ b/src/templates/analytics.rs @@ -42,6 +42,7 @@ pub struct UrlAnalyticsTemplate { pub page_end: usize, pub date_from: Option, pub date_to: Option, + pub is_admin: bool, } impl UrlAnalyticsTemplate { @@ -84,6 +85,7 @@ pub struct PageAnalyticsTemplate { pub page_end: usize, pub date_from: Option, pub date_to: Option, + pub is_admin: bool, } impl PageAnalyticsTemplate { diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 53d27f8..f426bde 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -276,6 +276,8 @@ pub struct HealthTemplate { pub tenants_db_size: String, pub job_history: Vec, pub health_checks: Vec, + pub registry_errors: Vec, + pub registry_warnings: Vec, pub csrf_token: String, pub success: Option, pub error: Option, @@ -372,49 +374,3 @@ impl IntoResponse for UserAnalyticsTemplate { } } } - -#[derive(Template)] -#[template(path = "user_url_analytics.html")] -pub struct UserUrlAnalyticsTemplate { - pub admin_username: String, - pub username: String, - pub url_code: String, - pub destination: String, - pub visits: Vec, -} - -impl IntoResponse for UserUrlAnalyticsTemplate { - fn into_response(self) -> Response { - match self.render() { - Ok(html) => Html(html).into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Render error: {}", e), - ) - .into_response(), - } - } -} - -#[derive(Template)] -#[template(path = "user_page_analytics.html")] -pub struct UserPageAnalyticsTemplate { - pub admin_username: String, - pub username: String, - pub page_code: String, - pub title: String, - pub visits: Vec, -} - -impl IntoResponse for UserPageAnalyticsTemplate { - fn into_response(self) -> Response { - match self.render() { - Ok(html) => Html(html).into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Render error: {}", e), - ) - .into_response(), - } - } -} diff --git a/src/web/admin.rs b/src/web/admin.rs index 3c5deaa..64c6a67 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -768,12 +768,29 @@ pub async fn user_urls_create( .into_response(); } - let is_slug_avail = { + { + 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(); - crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) - }; - if !is_slug_avail { - return Redirect::to("/user/urls?error=Short code/slug already exists").into_response(); + 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(); @@ -864,18 +881,17 @@ pub async fn user_urls_create( match res { Ok(url) => { - let _ = crate::db::users::increment_quota_counter( - &state.users_db.lock().unwrap(), - user.id, - "urls", - ); - let _ = crate::db::users::register_global_slug( - &state.system_db.lock().unwrap(), - &code, - user.id, - "url", - &url.id, - ); + { + 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, @@ -888,12 +904,11 @@ pub async fn user_urls_create( ); Redirect::to("/user/urls").into_response() } - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - Redirect::to("/user/urls?error=Short code/slug already exists").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() } - Err(e) => Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response(), } } @@ -1067,12 +1082,30 @@ pub async fn user_pages_create( return Redirect::to("/user/pages?error=Slug is required").into_response(); } - let is_slug_avail = { + { + 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(); - crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) - }; - if !is_slug_avail { - return Redirect::to("/user/pages?error=Short code/slug already exists").into_response(); + 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 = { @@ -1089,18 +1122,22 @@ pub async fn user_pages_create( match res { Ok(page) => { - let _ = crate::db::users::increment_quota_counter( - &state.users_db.lock().unwrap(), - user.id, - "landings", - ); - let _ = crate::db::users::register_global_slug( - &state.system_db.lock().unwrap(), - &code, - user.id, - "page", - &page.id, - ); + { + 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(), @@ -1114,12 +1151,11 @@ pub async fn user_pages_create( ); Redirect::to("/user/pages").into_response() } - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - Redirect::to("/user/pages?error=Short code already exists").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() } - Err(e) => Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response(), } } @@ -1411,23 +1447,52 @@ pub async fn user_restore_backup_post( .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() + .into_response(); } }; let tar_gz = GzDecoder::new(file); let mut archive = tar::Archive::new(tar_gz); - if let Err(e) = archive.unpack(&user_dir) { - Err(e) + if let Err(e) = archive.unpack(&temp_unpack_dir) { + Err(Box::new(e) as Box) } else { - Ok(()) + // 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()) + } } }; @@ -1435,6 +1500,39 @@ pub async fn user_restore_backup_post( 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( @@ -1451,6 +1549,7 @@ pub async fn user_restore_backup_post( .into_response() } Err(e) => { + let _ = std::fs::remove_dir_all(&temp_unpack_dir); Redirect::to(&format!("/user/settings?error=Restore failed: {}", e)).into_response() } } @@ -1665,6 +1764,7 @@ pub async fn urls_create( } 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(); @@ -1758,6 +1858,33 @@ pub async fn urls_create( 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(); + } + if let Err(e) = crate::db::users::register_global_slug( + &system_conn, + &code, + admin_user_id, + "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( @@ -1775,6 +1902,18 @@ pub async fn urls_create( 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( @@ -1790,12 +1929,11 @@ pub async fn urls_create( } Redirect::to("/admin/urls").into_response() } - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - Redirect::to("/admin/urls?error=Short code/slug already exists").into_response() + Err(e) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, admin_user_id); + Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response() } - Err(e) => Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response(), } } @@ -2264,6 +2402,7 @@ pub async fn pages_create( } 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(); @@ -2291,6 +2430,33 @@ pub async fn pages_create( 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(); + } + if let Err(e) = crate::db::users::register_global_slug( + &system_conn, + &code, + admin_user_id, + "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( @@ -2305,6 +2471,26 @@ pub async fn pages_create( 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( @@ -2320,12 +2506,9 @@ pub async fn pages_create( } Redirect::to("/admin/pages").into_response() } - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - Redirect::to("/admin/pages?error=Short code already exists").into_response() - } Err(e) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, admin_user_id); Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response() } } @@ -3786,6 +3969,7 @@ pub async fn url_analytics_get( page_end, date_from: clean_date_from, date_to: clean_date_to, + is_admin: true, }; template.into_response() @@ -3947,6 +4131,7 @@ pub async fn page_analytics_get( page_end, date_from: clean_date_from, date_to: clean_date_to, + is_admin: true, }; template.into_response() @@ -5390,6 +5575,22 @@ pub async fn health_get( 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(); + crate::db::users::verify_global_slug_registry_integrity( + &system_conn, + &users_conn, + &state.config.data_dir, + ) + .unwrap_or_else(|e| { + ( + vec![format!("Failed to run integrity check: {}", e)], + vec![], + ) + }) + }; + let template = crate::templates::HealthTemplate { admin_username: user.username, db_reports, @@ -5400,6 +5601,8 @@ pub async fn health_get( tenants_db_size, job_history, health_checks, + registry_errors, + registry_warnings, csrf_token, success: params.get("success").cloned(), error: params.get("error").cloned(), @@ -5914,12 +6117,33 @@ 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(), @@ -5934,61 +6158,152 @@ pub async fn user_url_analytics_get( } }; - let visits = { - let conn = user_dbs.analytics.lock().unwrap(); - let mut stmt = conn - .prepare( - "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code - FROM visits WHERE target_type = 'url' AND target_id = ?1 ORDER BY timestamp DESC;" - ) - .unwrap(); - let rows = stmt - .query_map([&url.id], |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()) - .enumerate() - .map(|(idx, r)| { - let (browser, _, _) = parse_ua(&r.user_agent); - let referrer = clean_referrer(&r.referer); - crate::templates::VisitorLogEntry { - sr: idx + 1, - 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 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::UserUrlAnalyticsTemplate { - admin_username: user.username.clone(), - username: user.username, - url_code: url.code, - destination: url.destination, + 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() @@ -5998,12 +6313,33 @@ 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(), @@ -6020,62 +6356,610 @@ pub async fn user_page_analytics_get( } }; - let visits = { - let conn = user_dbs.analytics.lock().unwrap(); - let mut stmt = conn - .prepare( - "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code - FROM visits WHERE target_type = 'page' AND target_id = ?1 ORDER BY timestamp DESC;" - ) - .unwrap(); - let rows = stmt - .query_map([&page.id], |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()) - .enumerate() - .map(|(idx, r)| { - let (browser, _, _) = parse_ua(&r.user_agent); - let referrer = clean_referrer(&r.referer); - crate::templates::VisitorLogEntry { - sr: idx + 1, - 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 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::UserPageAnalyticsTemplate { - admin_username: user.username.clone(), - username: user.username, - page_code: page.code, - title: page.title, + 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/api.rs b/src/web/api.rs index be9ef99..b26fc77 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -149,20 +149,105 @@ pub async fn api_create_url( None }; + // Dynamically resolve target user ID and content DB + let (target_user_id, content_db) = match user.0 { + crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), + crate::models::ApiActor::User(ref u) => { + let user_dbs = match state.get_user_dbs(u.id) { + Ok(dbs) => dbs, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: "Database error".to_string(), + }), + ) + .into_response() + } + }; + (u.id, user_dbs.content.clone()) + } + }; + + // Check quota + { + let users_conn = state.users_db.lock().unwrap(); + if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "urls") + .unwrap_or(false) + { + return ( + StatusCode::FORBIDDEN, + Json(ApiError { + error: "Quota limit exceeded".to_string(), + }), + ) + .into_response(); + } + } + + // Check availability + { + let system_conn = state.system_db.lock().unwrap(); + if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { + return ( + StatusCode::CONFLICT, + Json(ApiError { + error: "Short code already exists".to_string(), + }), + ) + .into_response(); + } + if let Err(e) = crate::db::users::register_global_slug( + &system_conn, + &code, + target_user_id, + "url", + "", + "reserving", + ) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: format!("Failed to reserve slug: {}", e), + }), + ) + .into_response(); + } + } + let tags = payload.tags.unwrap_or_default(); - let conn = state.content_db.lock().unwrap(); - match crate::db::content::create_url_extended( - &conn, - &code, - &dest, - payload.title.as_deref(), - payload.description.as_deref(), - &tags, - payload.expires_at.as_deref(), - password_hash.as_deref(), - payload.max_access_count, - ) { + let res = { + let conn = content_db.lock().unwrap(); + crate::db::content::create_url_extended( + &conn, + &code, + &dest, + payload.title.as_deref(), + payload.description.as_deref(), + &tags, + payload.expires_at.as_deref(), + password_hash.as_deref(), + payload.max_access_count, + ) + }; + + match res { Ok(url) => { + // Activate slug + { + 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], + ); + } + // Increment quota + { + let users_conn = state.users_db.lock().unwrap(); + let _ = + crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls"); + } + let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( @@ -189,24 +274,17 @@ pub async fn api_create_url( } (StatusCode::CREATED, Json(url)).into_response() } - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { + Err(e) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id); ( - StatusCode::CONFLICT, + StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { - error: "Short code already exists".to_string(), + error: e.to_string(), }), ) .into_response() } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError { - error: e.to_string(), - }), - ) - .into_response(), } } @@ -434,16 +512,109 @@ pub async fn api_create_page( } } - let conn = state.content_db.lock().unwrap(); - match create_landing_page( - &conn, - &code, - &payload.slug, - &payload.title, - &payload.html_content, - &payload.state, - ) { + // Dynamically resolve target user ID and content DB + let (target_user_id, content_db) = match user.0 { + crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), + crate::models::ApiActor::User(ref u) => { + let user_dbs = match state.get_user_dbs(u.id) { + Ok(dbs) => dbs, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: "Database error".to_string(), + }), + ) + .into_response() + } + }; + (u.id, user_dbs.content.clone()) + } + }; + + // Check quota + { + let users_conn = state.users_db.lock().unwrap(); + if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "landings") + .unwrap_or(false) + { + return ( + StatusCode::FORBIDDEN, + Json(ApiError { + error: "Quota limit exceeded".to_string(), + }), + ) + .into_response(); + } + } + + // Check availability + { + let system_conn = state.system_db.lock().unwrap(); + if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) { + return ( + StatusCode::CONFLICT, + Json(ApiError { + error: "Short code already exists".to_string(), + }), + ) + .into_response(); + } + if let Err(e) = crate::db::users::register_global_slug( + &system_conn, + &code, + target_user_id, + "page", + "", + "reserving", + ) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: format!("Failed to reserve slug: {}", e), + }), + ) + .into_response(); + } + } + + let res = { + let conn = content_db.lock().unwrap(); + create_landing_page( + &conn, + &code, + &payload.slug, + &payload.title, + &payload.html_content, + &payload.state, + ) + }; + + match res { Ok(page) => { + // Activate slug + { + let system_conn = state.system_db.lock().unwrap(); + let global_status = if payload.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], + ); + } + // Increment quota + { + let users_conn = state.users_db.lock().unwrap(); + let _ = crate::db::users::increment_quota_counter( + &users_conn, + target_user_id, + "landings", + ); + } + let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let _ = write_audit_log( @@ -457,24 +628,17 @@ pub async fn api_create_page( ); (StatusCode::CREATED, Json(page)).into_response() } - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { + Err(e) => { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id); ( - StatusCode::CONFLICT, + StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { - error: "Short code already exists".to_string(), + error: e.to_string(), }), ) .into_response() } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError { - error: e.to_string(), - }), - ) - .into_response(), } } diff --git a/src/web/bulk.rs b/src/web/bulk.rs index 45404a0..b99531c 100644 --- a/src/web/bulk.rs +++ b/src/web/bulk.rs @@ -165,7 +165,53 @@ pub async fn api_bulk_url( .into_response(); } - let mut conn = state.content_db.lock().unwrap(); + // Dynamically resolve target user ID and content DB + let (target_user_id, content_db) = match user.0 { + crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()), + crate::models::ApiActor::User(ref u) => { + let user_dbs = match state.get_user_dbs(u.id) { + Ok(dbs) => dbs, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(BulkErrorResponse { + error: "Database error".to_string(), + }), + ) + .into_response() + } + }; + (u.id, user_dbs.content.clone()) + } + }; + + // 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(); + } + } + + let mut conn = content_db.lock().unwrap(); let tx = match conn.transaction() { Ok(t) => t, Err(e) => { @@ -180,6 +226,7 @@ pub async fn api_bulk_url( }; 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(); @@ -188,6 +235,12 @@ pub async fn api_bulk_url( } 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 { @@ -198,11 +251,66 @@ pub async fn api_bulk_url( } } + // 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 { @@ -229,20 +337,13 @@ pub async fn api_bulk_url( item.max_access_count, ) { Ok(url) => created_urls.push(url), - Err(rusqlite::Error::SqliteFailure(err, _)) - if err.code == rusqlite::ErrorCode::ConstraintViolation => - { - let _ = tx.rollback(); - return ( - StatusCode::CONFLICT, - Json(BulkErrorResponse { - error: format!("Short code '{}' already exists", code), - }), - ) - .into_response(); - } 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 { @@ -255,6 +356,10 @@ pub async fn api_bulk_url( } 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 { @@ -264,6 +369,25 @@ pub async fn api_bulk_url( .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()); diff --git a/src/web/pages.rs b/src/web/pages.rs index 65d5745..095f660 100644 --- a/src/web/pages.rs +++ b/src/web/pages.rs @@ -63,14 +63,19 @@ pub async fn resolve_page( .into_response(); } - // 2. Get user specific database connections - let user_dbs = match state.get_user_dbs(owner_user_id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + // 2. Get content database connection - admin (user_id=1) uses legacy content_db, + // tenant users use per-user content databases + let content_conn = if owner_user_id == 1 { + state.content_db.clone() + } else { + match state.get_user_dbs(owner_user_id) { + Ok(dbs) => dbs.content, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + } }; let page_opt = { - let conn = user_dbs.content.lock().unwrap(); + let conn = content_conn.lock().unwrap(); match crate::db::content::get_landing_page_by_code(&conn, &code) { Ok(page) => page, Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), diff --git a/src/web/qr.rs b/src/web/qr.rs index e80faf1..c9217e3 100644 --- a/src/web/qr.rs +++ b/src/web/qr.rs @@ -10,7 +10,6 @@ use std::net::SocketAddr; use serde_json::json; -// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef) // GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef) pub async fn qr_handler( State(state): State, @@ -38,12 +37,12 @@ pub async fn qr_handler( return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } - // We need to look up owner_user_id and status from global_slugs - let (owner_user_id, slug_status) = { + // We need to look up owner_user_id, target_id, and status from global_slugs + let (owner_user_id, target_id, slug_status) = { let system_conn = state.system_db.lock().unwrap(); - let mut stmt = match system_conn - .prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;") - { + let mut stmt = match system_conn.prepare( + "SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;", + ) { Ok(s) => s, Err(_) => { return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() @@ -52,19 +51,25 @@ pub async fn qr_handler( use rusqlite::OptionalExtension; match stmt .query_row(rusqlite::params![&file], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) }) .optional() { - Ok(Some((uid, status))) => (uid, status), - Ok(None) => (1, "active".to_string()), // fallback to admin + Ok(Some((uid, tid, status))) => (uid, tid, status), + Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(), Err(_) => { return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() } } }; - if slug_status != "active" { + if slug_status == "disabled" { + return (StatusCode::GONE, "This content has been disabled").into_response(); + } else if slug_status != "active" { return (StatusCode::NOT_FOUND, "URL not found").into_response(); } @@ -73,31 +78,16 @@ pub async fn qr_handler( Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }; - let url_opt = { - let conn = user_dbs.content.lock().unwrap(); - match crate::db::content::get_url_by_code(&conn, &file) { - Ok(u) => u, - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() - } - } - }; - - let url = match url_opt { - Some(u) => u, - None => return (StatusCode::NOT_FOUND, "URL not found").into_response(), - }; - let qr_scans = { let conn = user_dbs.analytics.lock().unwrap(); - crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0) + crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0) }; let direct_clicks = { let conn = user_dbs.analytics.lock().unwrap(); conn.query_row( - "SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;", - rusqlite::params![url.id], + "SELECT COUNT(*) FROM visits WHERE target_id = ?1;", + rusqlite::params![target_id], |row| row.get(0), ) .unwrap_or(0) @@ -113,15 +103,17 @@ pub async fn qr_handler( let code = parts[0]; let ext = parts[1].to_lowercase(); - if !crate::utils::validation::validate_redirect_code(code) { + if !crate::utils::validation::validate_redirect_code(code) + && !crate::utils::validation::validate_page_code(code) + { return (StatusCode::NOT_FOUND, "Not Found").into_response(); } - // We need to look up owner_user_id and status from global_slugs - let (owner_user_id, slug_status) = { + // We need to look up owner_user_id, target_type, target_id, and status from global_slugs + let (owner_user_id, target_type, target_id, slug_status) = { let system_conn = state.system_db.lock().unwrap(); let mut stmt = match system_conn - .prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;") + .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(), @@ -129,38 +121,27 @@ pub async fn qr_handler( use rusqlite::OptionalExtension; match stmt .query_row(rusqlite::params![code], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) }) .optional() { - Ok(Some((uid, status))) => (uid, status), - Ok(None) => (1, "active".to_string()), // fallback to admin + Ok(Some(info)) => info, + Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), } }; - if slug_status != "active" { - return (StatusCode::NOT_FOUND, "Url not found").into_response(); + if slug_status == "disabled" { + return (StatusCode::GONE, "This content has been disabled").into_response(); + } else if slug_status != "active" { + return (StatusCode::NOT_FOUND, "Not Found").into_response(); } - let user_dbs = match state.get_user_dbs(owner_user_id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - }; - - let url_opt = { - let conn = user_dbs.content.lock().unwrap(); - match crate::db::content::get_url_by_code(&conn, code) { - Ok(u) => u, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), - } - }; - - let url = match url_opt { - Some(u) => u, - None => return (StatusCode::NOT_FOUND, "Url not found").into_response(), - }; - // Construct public base URL let proto = if state.config.cookie_secure { "https" @@ -178,7 +159,11 @@ pub async fn qr_handler( .clone() .unwrap_or_else(|| format!("{}://{}", proto, host_header)); - let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code); + let full_url = if target_type == "page" { + format!("{}/p/{}", base_url.trim_end_matches('/'), code) + } else { + format!("{}/{}", base_url.trim_end_matches('/'), code) + }; // Generate QR code based on format let (body, content_type) = if ext == "svg" { @@ -211,22 +196,25 @@ pub async fn qr_handler( .into_response(); }; - // Log the QR access event - let ip = get_client_ip(&headers, connect_info); - let user_agent = headers - .get("user-agent") - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_string()); + // Log the QR access event in a try-catch style + let _ = { + let ip = get_client_ip(&headers, connect_info); + let user_agent = headers + .get("user-agent") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); - { - let analytics_conn = user_dbs.analytics.lock().unwrap(); - let _ = crate::db::qr::log_qr_access( - &analytics_conn, - &url.id, - Some(ip.as_str()), - user_agent.as_deref(), - ); - } + if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) { + if let Ok(analytics_conn) = user_dbs.analytics.lock() { + let _ = crate::db::qr::log_qr_access( + &analytics_conn, + &target_id, + Some(ip.as_str()), + user_agent.as_deref(), + ); + } + } + }; Response::builder() .header("content-type", content_type) diff --git a/src/web/redirect.rs b/src/web/redirect.rs index 3fb1c4f..8204eae 100644 --- a/src/web/redirect.rs +++ b/src/web/redirect.rs @@ -24,8 +24,10 @@ pub async fn resolve_redirect( headers: HeaderMap, connect_info: Option>, ) -> Response { - // Basic validation of code (must be 6 hex characters or a valid custom slug) - if !crate::utils::validation::validate_redirect_code(&code) { + // Basic validation of code (must be 6 hex characters, 4 hex characters, or a valid custom slug) + if !crate::utils::validation::validate_redirect_code(&code) + && !crate::utils::validation::validate_page_code(&code) + { return (StatusCode::NOT_FOUND, "Not Found").into_response(); } @@ -49,7 +51,7 @@ pub async fn resolve_redirect( .optional() }; - let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info { + 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 @@ -67,14 +69,24 @@ pub async fn resolve_redirect( .into_response(); } - // 2. Get user specific database connections - let user_dbs = match state.get_user_dbs(owner_user_id) { - Ok(dbs) => dbs, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").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 - admin (user_id=1) uses legacy content_db, + // tenant users use per-user content databases + let content_conn = if owner_user_id == 1 { + state.content_db.clone() + } else { + 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 = user_dbs.content.lock().unwrap(); + 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(), @@ -96,7 +108,7 @@ pub async fn resolve_redirect( if expires_at.with_timezone(&Utc) < Utc::now() { // Mark as expired in DB asynchronously/immediately { - let conn = user_dbs.content.lock().unwrap(); + let conn = content_conn.lock().unwrap(); let _ = conn.execute( "UPDATE urls SET expired = 1 WHERE id = ?1;", [url.id.clone()], @@ -131,12 +143,12 @@ pub async fn resolve_redirect( // 6. Increment access count & retrieve preview config let _new_access_count = { - let conn = user_dbs.content.lock().unwrap(); + 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 = user_dbs.content.lock().unwrap(); + let conn = content_conn.lock().unwrap(); crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None) }; @@ -188,6 +200,14 @@ pub async fn resolve_redirect( } .into_response() } else { - Redirect::temporary(&url.destination).into_response() + { + 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 + } } } diff --git a/src/web/routes.rs b/src/web/routes.rs index 7c406b2..d9fec6a 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -52,10 +52,26 @@ pub fn create_router(state: AppState) -> Router { "/user/analytics/url/:id", get(admin::user_url_analytics_get), ) + .route( + "/user/analytics/url/:id/export/csv", + get(admin::user_url_analytics_csv_export), + ) + .route( + "/user/analytics/url/:id/export/json", + get(admin::user_url_analytics_json_export), + ) .route( "/user/analytics/page/:id", get(admin::user_page_analytics_get), ) + .route( + "/user/analytics/page/:id/export/csv", + get(admin::user_page_analytics_csv_export), + ) + .route( + "/user/analytics/page/:id/export/json", + get(admin::user_page_analytics_json_export), + ) .route("/api-tokens", get(admin::api_tokens_get)) .route("/api-tokens/create", post(admin::api_tokens_create_post)) .route( diff --git a/templates/components/qr_preview.html b/templates/components/qr_preview.html new file mode 100644 index 0000000..0234ec5 --- /dev/null +++ b/templates/components/qr_preview.html @@ -0,0 +1,9 @@ + + + QR + +
+ PNG + SVG +
+ diff --git a/templates/health.html b/templates/health.html index 6415736..965f4a1 100644 --- a/templates/health.html +++ b/templates/health.html @@ -7,6 +7,38 @@ {% block header_title %}System Health Dashboard{% endblock %} {% block content %} +{% if !registry_errors.is_empty() || !registry_warnings.is_empty() %} +
+ {% if !registry_errors.is_empty() %} +
+

+ + Global Registry Errors (Action Required) +

+
    + {% for err in registry_errors %} +
  • {{ err }}
  • + {% endfor %} +
+
+ {% endif %} + + {% if !registry_warnings.is_empty() %} +
+

+ + Global Registry Warnings (Attention Needed) +

+
    + {% for warn in registry_warnings %} +
  • {{ warn }}
  • + {% endfor %} +
+
+ {% endif %} +
+{% endif %} +
diff --git a/templates/page_analytics.html b/templates/page_analytics.html index 3e7ad6c..5ed0c67 100644 --- a/templates/page_analytics.html +++ b/templates/page_analytics.html @@ -4,17 +4,111 @@ {% block active_pages %}active{% endblock %} +{% block sidebar_links %} +{% if is_admin %} +
  • + + + Dashboard + +
  • +
  • + + + Short URLs + +
  • +
  • + + + Landing Pages + +
  • +
  • + + + Users Management + +
  • +
  • + + + Settings + +
  • +
  • + + + Audit Log + +
  • +
  • + + + Status + +
  • +{% else %} +
  • + + + Dashboard + +
  • +
  • + + + Short URLs + +
  • +
  • + + + Landing Pages + +
  • +
  • + + + Settings + +
  • +
  • + + + Audit Log + +
  • +
  • + + + Status + +
  • +{% endif %} +{% endblock %} + +{% block sidebar_footer %} + +{% endblock %} + {% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %} {% block header_actions %}
    - + 📥 Export CSV - + 📥 Export JSON - + Back to Landing Pages @@ -24,7 +118,7 @@ {% block content %}
    -
    +
    @@ -34,7 +128,7 @@
    - Clear + Clear
    @@ -195,8 +289,8 @@