46 Commits
Author SHA1 Message Date
thakares 7dfb8c0f1b BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation 2026-06-19 14:51:38 +05:30
thakares 743502b183 ci: improve Rust workflow checks 2026-06-18 15:31:52 +05:30
thakares 7ee6ab2feb ci: rerun workflow 2026-06-18 15:25:06 +05:30
thakares 621a1eccdc Add project comparison guide and improve landing page 2026-06-18 15:09:09 +05:30
thakares cefb84f643 Add project comparison guide and improve landing page 2026-06-18 15:02:31 +05:30
thakares 9fae39dfa4 Use standard Rust dual-license layout 2026-06-18 13:07:34 +05:30
thakares 2212701b6b Add Apache 2.0 license file 2026-06-18 13:04:28 +05:30
thakares 536d885a3d Add API documentation and dual MIT/Apache licensing 2026-06-18 12:59:07 +05:30
thakares a4ffe9a509 Delete LICENSE-APACHE 2026-06-18 12:39:03 +05:30
thakares ad05af95e9 Add API documentation and dual MIT/Apache licensing 2026-06-18 12:32:51 +05:30
thakares 17d8618443 docs: add REST API documentation and installation guide 2026-06-18 12:17:16 +05:30
thakares 74524cb7d2 docs: add one-command deployment section with deploy.sh 2026-06-17 20:45:54 +05:30
thakares e2140e6614 Add deploy script endpoint and improve landing page 2026-06-17 20:33:25 +05:30
thakares 1a9bf096f3 Add deploy.sh endpoint and one-command installation flow 2026-06-17 20:17:19 +05:30
thakares 900f72a299 release: update installer 2026-06-17 19:08:19 +05:30
thakares d42f6da94a feat: production deployment script with rollback and multi-arch support 2026-06-17 19:00:41 +05:30
thakares 18d8b16a2c docs: update documentation for v0.4.0 release 2026-06-17 16:49:47 +05:30
thakares f6dcf58b79 Add analytics drill-down, visitor logs, exports and pagination 2026-06-17 15:47:14 +05:30
thakares 84c48fa5c1 Add analytics drill-down, visitor logs, exports and pagination 2026-06-17 15:35:59 +05:30
thakares 0e111d61ff docs: add Docker deployment guide 2026-06-14 21:02:36 +05:30
thakares 81eb8950dd ci: add automatic Docker image push to GHCR on main 2026-06-14 20:53:32 +05:30
thakares 914563e883 ci: fix Docker image loading in CI health check 2026-06-14 20:49:59 +05:30
thakares 8b521f1a71 ci: fix YAML structure in GitHub workflow 2026-06-14 20:46:55 +05:30
thakares f1d72c8cbe Update rust.yml 2026-06-14 20:40:22 +05:30
thakares d9979ba04c docs: refine README and project documentation 2026-06-14 19:50:12 +05:30
thakares 6f42b43608 docs: refine README and project documentation 2026-06-14 19:44:36 +05:30
thakares 3e9dc47604 docs: refine README and project documentation 2026-06-14 19:43:46 +05:30
thakares e2e61fc412 docs: refine README and project documentation 2026-06-14 19:42:35 +05:30
thakares a0a73b918c docs: refine README and project documentation 2026-06-14 19:36:29 +05:30
thakares d2174aa111 docs: refine README and project documentation 2026-06-14 19:26:21 +05:30
thakares 6a45474e97 docs: refine README and project documentation 2026-06-14 19:24:38 +05:30
thakares c6486763e3 Add custom slugs, restore UI, UTM builder, and CLI link tools 2026-06-14 19:11:21 +05:30
thakares 02a26cfd94 style: fix cargo fmt issues in pages.rs and root_landing_tests.rs 2026-06-13 22:29:03 +05:30
thakares 3c0a1bdd91 ci: enhance GitHub workflow with Docker build + better Rust CI 2026-06-13 22:24:37 +05:30
thakares ee6d3135d5 docker: optimize build cache with better layer ordering 2026-06-13 22:20:25 +05:30
thakares 671370571a chore: add .dockerignore for cleaner Docker builds 2026-06-13 22:13:28 +05:30
thakares 42a2df33dd Add root landing page support and package static assets 2026-06-13 21:47:14 +05:30
thakares 80038fb851 docs: improve testing guide and add README documentation links 2026-06-13 18:48:26 +05:30
thakares 9c5e3e4d58 Add TESTING.md with validation and recovery procedures 2026-06-13 18:35:05 +05:30
thakares 27c4ad6805 Update README for v0.2.0 feature set 2026-06-12 20:17:18 +05:30
thakares 592154e961 Update README for v0.2.0 feature set 2026-06-12 20:13:22 +05:30
thakares bcbcc90d98 Add QR codes, link expiry, password protection, previews, audit trail and bulk operations 2026-06-12 19:57:53 +05:30
thakares 157aa81252 Add static web assets 2026-06-11 22:12:12 +05:30
thakares a55f40dc29 Fix Docker build for Rust 2024 edition 2026-06-11 22:11:09 +05:30
thakares a516ecedb0 Update README with screenshots 2026-06-11 20:52:28 +05:30
thakares 50718e57f0 Add admin interface screenshots 2026-06-11 20:49:01 +05:30
179 changed files with 26863 additions and 1101 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
# Dependencies & Build artifacts
target/
**/target/
# Environment & secrets
.env
.env.*
*.key
*.pem
# Development & testing files
.git/
.gitignore
.github/
.gitattributes
# Documentation & notes
README*.md
docs/
CONTRIBUTING.md
CHANGELOG.md
LICENSE
# Logs & temporary files
*.log
*.tmp
data/
backups/
# Editor & IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Docker related
Dockerfile*
.dockerignore
docker-compose*.yml
.docker/
# Test files
tests/
__tests__/
*.test.*
*.spec.*
# Other unnecessary files
node_modules/
dist/
build/
+80 -17
View File
@@ -1,22 +1,85 @@
name: Rust
name: Rust CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
env:
CARGO_TERM_COLOR: always
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
jobs:
test:
name: Test & Quality Checks
runs-on: ubuntu-latest
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache Cargo Dependencies
uses: Swatinem/rust-cache@v2
- name: Check Formatting
run: cargo fmt --check
- name: Run Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Build Release
run: cargo build --release --verbose
- name: Run Tests
run: cargo test --all-features --verbose
docker:
name: Build Docker Image
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -5,3 +5,4 @@ data/
*.db-shm
.env
.idea/
Generated
+790 -2
View File
File diff suppressed because it is too large. Load diff
+12 -4
View File
@@ -1,11 +1,12 @@
[package]
name = "bzod"
version = "0.1.0"
version = "0.5.0"
edition = "2021"
license = "MIT OR Apache-2.0"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] }
axum = { version = "0.7", features = ["macros", "multipart"] }
axum-extra = { version = "0.9", features = ["cookie"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
@@ -19,12 +20,19 @@ askama = { version = "0.12" }
argon2 = "0.5"
sha2 = "0.10"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies"] }
tar = "0.4"
flate2 = "1.0"
chrono = { version = "0.4", features = ["serde"] }
hex = "0.4"
time = "0.3"
toml = "0.8"
qrcode = "0.14"
image = "0.25"
zip = { version = "2.1", default-features = false, features = ["deflate"] }
futures-util = "0.3"
zstd = "0.13"
[lints.clippy]
let_unit_value = "allow"
useless_vec = "allow"
+30 -24
View File
@@ -1,7 +1,7 @@
# ==========================================
# Stage 1: Build
# Stage 1: Builder (with optimized caching)
# ==========================================
FROM rust:1.82-slim-bookworm AS builder
FROM rust:1.89-bookworm AS builder
WORKDIR /app
@@ -9,33 +9,33 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy configuration files
COPY Cargo.toml ./
# Copy only Cargo files first (best caching)
COPY Cargo.toml Cargo.lock ./
# Pre-build dependencies to cache them
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Create dummy source for dependency caching
RUN mkdir -p src && \
echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
cargo build --release && \
rm -rf src target/release/deps/bzod*
# Copy source and templates
# Copy real source code + assets
COPY src ./src
COPY templates ./templates
COPY www ./www
# Trigger rebuilding with actual source
RUN touch src/main.rs
# Build the real application
RUN cargo build --release
# ==========================================
# Stage 2: Runner
# Stage 2: Runtime (slim)
# ==========================================
FROM debian:bookworm-slim
WORKDIR /app
# Install runtime dependencies
# Runtime dependencies
RUN apt-get update && apt-get install -y \
openssl \
ca-certificates \
@@ -45,23 +45,29 @@ RUN apt-get update && apt-get install -y \
# Copy binary from builder
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
# Create non-root user and data directory
RUN groupadd -g 10001 bzod && \
useradd -u 10001 -g bzod -m -s /bin/bash bzod
# Copy assets
COPY --from=builder /app/templates ./templates
COPY --from=builder /app/www ./www
RUN mkdir -p /app/data && chown -R bzod:bzod /app/data
# Create non-root user
RUN groupadd -g 1000 bzod && \
useradd -u 1000 -g bzod -m -s /bin/bash bzod
# Create data directory
RUN mkdir -p /app/data && \
chown -R bzod:bzod /app
USER bzod
ENV DATA_DIR=/app/data
ENV PORT=8080
ENV HOST=0.0.0.0
ENV COOKIE_SECURE=true
ENV DATA_DIR=/app/data \
PORT=8654 \
HOST=0.0.0.0 \
COOKIE_SECURE=true
EXPOSE 8080
EXPOSE 8654
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:$${PORT:-8080}/status || exit 1
CMD curl -f http://localhost:${PORT}/status || exit 1
ENTRYPOINT ["bzod"]
CMD ["serve"]
View File
File renamed without changes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sunil Purushottam Thakare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+533 -66
View File
@@ -1,90 +1,493 @@
# nx9-url-shortner
# nx9-url-shortener (the BZOD daemon)
A lightweight, self-hosted URL shortener and landing page platform written in Rust.
**A lightweight, self-hosted URL management platform written in Rust.**
`nx9-url-shortner` is designed for individuals, organizations, and homelab operators who want complete control over their short links without relying on third-party services.
BZOD combines URL shortening, landing pages, QR code generation, password-protected links, smart preview pages, analytics, audit logging, lifecycle management, backup/restore, and API automation into a single self-hosted application with zero external service dependencies.
Built with Rust, SQLite, Axum, and Askama, it provides URL shortening, landing pages, analytics, audit logging, API access, and a web-based administration interface while maintaining a small deployment footprint.
Built with Rust, SQLite, Axum, and Askama, BZOD is designed for individuals, organizations, homelab operators, government agencies, and businesses that want complete ownership of their links, analytics, and branding.
---
## License
Licensed under either of
- Apache License, Version 2.0
([LICENSE](LICENSE-APACHE)) - Default
- MIT License
([LICENSE-MIT](LICENSE-MIT))
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in this project shall be dual licensed under
the terms above without any additional terms or conditions.
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)
---
## Highlights
### v0.4.0
* Raw visitor activity logs
* Analytics drill-down pages
* Visitor pagination
* Advanced sliding-window pagination
* CSV analytics export
* JSON analytics export
* Date-filtered analytics
* Landing page analytics
* Human-readable custom slugs
* Root landing page support
* UTM campaign builder
* Built-in backup and restore
* CLI shorten command
* CLI expand command
* QR code generation
* Password-protected links
* Smart preview pages
* Analytics dashboard
* Audit logging
* Health monitoring
* Human-readable custom slugs
* Root landing page support
* Landing page custom slugs
* UTM campaign builder
* Built-in backup and restore
* CLI shorten command
* CLI expand command
* QR code generation (PNG/SVG)
* Password-protected links
* Smart preview pages
* Analytics dashboard
* Audit logging
* Health monitoring
---
## Feature Matrix
| Feature | Status |
|---------------------------| ------ |
| URL Shortening | ✅ |
| Custom Slugs | ✅ |
| Landing Pages | ✅ |
| Landing Page Custom Slugs | ✅ |
| QR Code Generation | ✅ |
| QR Analytics | ✅ |
| Password-Protected Links | ✅ |
| Smart Preview Pages | ✅ |
| Link Expiration | ✅ |
| One/Multi-Time Links | ✅ |
| Audit Trail | ✅ |
| Analytics Dashboard | ✅ |
| Health Monitoring | ✅ |
| REST API | ✅ |
| Backup & Restore | ✅ |
| UTM Campaign Builder | ✅ |
| CLI Automation | ✅ |
| Raw Visitor Logs | ✅ |
| Analytics Export (CSV) | ✅ |
| Analytics Export (JSON) | ✅ |
| Analytics Drill-Down | ✅ |
| Date Range Analytics | ✅ |
| Advanced Pagination | ✅ |
| Geo Analytics | 🚧 |
| Multi-User Administration | 🚧 |
| SSO | 🚧 |
---
## One-Command Installation (Recommended)
```bash
curl -fsSL https://bzo.in/deploy.sh | sudo bash
```
---
## Features
### URL Shortening
Create short links using compact hexadecimal identifiers.
Create compact short URLs using automatically generated hexadecimal identifiers.
Example:
```text
https://<your-domain>/1bb170
```
Redirects to:
```text
https://very-long-domain-name.com
https://your-domain/1bb170
```
---
## Design Principles
BZOD is intentionally designed around a few principles:
- Self-hosted first
- SQLite-first architecture
- Minimal operational complexity
- Recovery over convenience
- Human-readable administration
- No external service dependencies
- No vendor lock-in
Features are added only when they improve usability without increasing architectural complexity.
### Custom Slugs
Create memorable human-readable links.
Examples:
```text
https://your-domain/!office
https://your-domain/!home
https://your-domain/!site
https://your-domain/!project-alpha
```
Features:
* Case-insensitive uniqueness
* Lowercase normalization
* Human-readable URLs
* No database schema changes
* Fully compatible with existing short codes
Examples:
```text
!office
!home
!warehouse
!meeting-room
!client_a
```
## Practical Examples
Generated URL
```
https://bzo.in/1b926e
```
Custom Slug
```
https://bzo.in/!myoffice
```
Landing Page
```
https://bzo.in/p/!company-profile
```
---
## CLI
```
bzod shorten https://example.com
bzod shorten https://example.com --slug !office
bzod expand !office
```
---
### Landing Pages
Create standalone landing pages using dedicated page identifiers.
Create standalone landing pages hosted directly by BZOD.
Example:
Generated page:
```text
https://<your-domain>/p/1a2b
https://your-domain/p/1a2b
```
Custom slug page:
```text
https://your-domain/p/!company-profile
https://your-domain/p/!product-launch
```
Features:
* Raw HTML support
* SEO slug support
* Published / Draft states
* Custom paths
* Open Graph metadata
---
### CLI Commands
Core commands
```bash
bzod serve
bzod create-admin
bzod doctor
bzod stats
```
URL management
```bash
bzod shorten https://example.com
bzod shorten https://example.com --slug !campaign
bzod expand 1bb170
bzod expand !campaign
```
Maintenance
```bash
bzod backup
bzod restore --file backup_20250614.tar.gz
bzod migrate
```
### QR Code Generation
Generate QR codes for every short URL.
Supported formats:
* PNG
* SVG
Features:
* Downloadable QR assets
* QR scan analytics
* Bulk QR export
* Print-friendly SVG output
---
### Password-Protected Links
Protect sensitive links using Argon2id password hashing.
Features:
* Password gate
* Secure session handling
* Access restrictions
* Audit logging
---
### Smart Preview Pages
Display branded preview pages before redirecting visitors.
Features:
* Custom title
* Description
* Logo support
* Open Graph metadata
* Social sharing previews
---
### Link Lifecycle Management
Control link validity.
Features:
* Expiration dates
* One-time links
* Access limits
* Administrative disable
* Automated expiry jobs
---
### UTM Campaign Builder
Append campaign tracking parameters when creating links.
Supported parameters:
```text
utm_source
utm_medium
utm_campaign
```
Example output:
```text
https://example.com/page?utm_source=email&utm_medium=newsletter&utm_campaign=launch
```
No additional database schema changes are required.
---
### Analytics
Track:
* Total visits
* Country statistics
* Unique visitors
* QR scans
* Countries
* Referrers
* User agents
* Daily statistics
* Monthly statistics
* Yearly statistics
Analytics drill-down includes:
* Raw visitor activity logs
* Visitor IP addresses
* Country information
* Referrer tracking
* Browser detection
* Raw User-Agent display
* Date range filtering
* CSV export
* JSON export
* Paginated visitor logs
Analytics Export
Export analytics data directly from the administration interface.
Supported formats:
* CSV
* JSON
Features:
* Memory-safe export handling
* Date-range filtering
* Downloadable files
* Compatible with spreadsheets and BI tools
---
### Audit Trail
Track administrative activity.
Recorded events include:
* Login
* Logout
* URL creation
* URL updates
* URL deletion
* Backup creation
* Restore operations
* QR exports
* Configuration changes
---
### Administrative Dashboard
Web-based administration interface featuring:
Web-based management interface.
* URL management
* Landing page management
Features:
* URL registry
* Landing pages
* QR management
* Analytics
* API token management
* Audit logs
* Health checks
* Analytics dashboard
* SVG charts
* Backup utilities
* Restore utilities
* Health monitoring
* Server diagnostics
---
### API Support
### REST API
REST API endpoints for automation and integration.
REST API support for automation and integrations.
Endpoint prefix:
```text
/api/v1/*
```
Supports:
* URL creation
* URL management
* Landing pages
* QR generation
* Analytics access
---
### CLI Automation
Create and manage links directly from the command line.
Examples:
Create automatic code:
```bash
bzod shorten https://example.com
```
Create custom slug:
```bash
bzod shorten https://example.com --slug !office
```
Expand code:
```bash
bzod expand 1bb170
```
Expand custom slug:
```bash
bzod expand !office
```
---
### Backup & Restore
BZOD includes integrated backup and restore functionality through both the CLI and Web UI.
CLI:
```bash
bzod backup
bzod restore --file backup.tar.gz
```
Web UI:
```text
Settings → Maintenance & DB Utilities
```
Features:
* Compressed tar.gz backups
* Full database restoration
* Backup validation
* Disaster recovery support
* No external tools required
Protected databases:
* admin.db
* content.db
* analytics.db
* system.db
---
### Security
* Password-protected administration interface
* Session management
* Password-protected administration
* Password-protected links
* Argon2id password hashing
* CSRF protection
* Session management
* API token authentication
* Audit logging
* Access controls
---
@@ -103,8 +506,10 @@ No:
* React
* Node.js
* Redis
* PostgreSQL
* MongoDB
* Kubernetes
* External databases
* SaaS dependencies
---
@@ -112,45 +517,101 @@ No:
### Databases
The application uses four SQLite databases.
BZOD uses four SQLite databases.
| Database | Purpose |
| ------------ | ---------------------------------------- |
| admin.db | Users, sessions, API keys, audit logs |
| content.db | URLs, landing pages, tags |
| analytics.db | Visits and statistics |
| system.db | Jobs, migrations, backups, health checks |
| Database | Purpose |
| ------------ | ------------------------------ |
| admin.db | Users, sessions, API keys |
| content.db | URLs, landing pages, metadata |
| analytics.db | Visits, QR scans, statistics |
| system.db | Audit events, jobs, monitoring |
---
## Default Credentials
## Initial Setup
Initial login:
### Native Installation
```text
Username: admin
Password: admin
```bash
cargo run -- create-admin
```
Change the password immediately after first login.
### Docker
```bash
docker exec -it bzod bzod create-admin
```
---
## Disaster Recovery Validation
The backup and restore system has been validated through a complete recovery workflow.
Validation procedure:
1. Create backup archive
2. Stop application
3. Restore backup
4. Restart application
5. Verify application integrity
Verified components:
* URL registry
* Landing pages
* Analytics
* Audit logs
* API tokens
* QR assets
* Settings
* Health monitoring
Expected outcome:
The application returns to a fully operational state without data loss.
---
## Screenshots
### Dashboard
![Dashboard](screenshots/dashboard.png)
### URL Management
![URL Management](screenshots/short-url-panel.png)
### Landing Pages
![Landing Pages](screenshots/landing-page-panel.png)
### Settings
![Settings](screenshots/settings.png)
### Server Status
![Server Status](screenshots/server-status.png)
---
## Docker Deployment
### Build
Build:
```bash
docker compose build
```
### Start
Start:
```bash
docker compose up -d
```
### View Logs
Logs:
```bash
docker logs -f bzod
@@ -184,31 +645,25 @@ services:
## Development
### Build
Build:
```bash
cargo build
```
### Run
Run:
```bash
cargo run -- serve
```
### Run Migrations
```bash
cargo run -- migrate
```
### Create Admin User
Create administrator:
```bash
cargo run -- create-admin
```
### Run Tests
Run tests:
```bash
cargo test
@@ -216,6 +671,18 @@ cargo test
---
## Development & Testing
See:
```text
docs/TESTING.md
```
for testing, validation, backup, restore, disaster recovery, and release procedures.
---
## Project Structure
```text
@@ -239,20 +706,17 @@ src/
Planned features:
* QR code generation
* Link expiration
* Link disabling
* CSV exports
* Bulk URL import
* GeoIP integration
* Geo analytics
* Multi-user administration
* SSO support
* SSO integration
* Signed temporary links
* OpenAPI documentation
---
## Production Deployment
Recommended stack:
Recommended architecture:
```text
Internet
@@ -261,7 +725,7 @@ Internet
Nginx Proxy Manager
│
▼
nx9-url-shortner
BZOD
│
▼
SQLite
@@ -269,6 +733,12 @@ SQLite
HTTPS is strongly recommended.
---
## Documentation
- [Testing Guide](docs/TESTING.md)
- [Docker Deployment Guide](docs/DOCKER-Deploy.md)
---
## License
@@ -281,7 +751,4 @@ Apache License 2.0
Sunil Purushottam Thakare
Built using Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
=======
# nx9-url-shortener
A simple, self-hosted URL shortener built with Rust and SQLite: no Node.js, no Redis, no external dependencies.
Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
+155 -76
View File
@@ -1,11 +1,9 @@
#!/usr/bin/env bash
# BZOD Deployment Script (Debian Native Deployment)
# This script sets up a secure, production-ready systemd service for BZOD.
# BZOD Production Deployment Script
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
set -euo pipefail
# Configurations
SERVICE_USER="bzod"
INSTALL_PATH="/usr/local/bin/bzod"
CONFIG_DIR="/etc/bzod"
@@ -13,93 +11,134 @@ DATA_DIR="/var/lib/bzod/data"
ENV_FILE="${CONFIG_DIR}/bzod.env"
SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
# Color outputs
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
NC='\033[0m'
echo -e "${BLUE}=== BZOD Debian Deployment Script ===${NC}"
# Temporary file cleanup
TMP_BINARY=""
cleanup() {
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
}
trap cleanup EXIT
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}"
echo -e "Production deployment started...\n"
# 1. Check Root Privileges
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (or via sudo).${NC}"
echo -e "${RED}Error: This script must be run as root (use sudo).${NC}"
exit 1
fi
# 2. Install Package Dependencies
echo -e "\n${BLUE}[1/8] Installing system dependencies (SQLite, OpenSSL, Tar)...${NC}"
apt-get update
# 1. Install Base Dependencies
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
apt-get update -qq
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
# 3. Compile Production Build Locally
echo -e "\n${BLUE}[2/8] Compiling release binary...${NC}"
if ! command -v cargo &> /dev/null; then
echo -e "${RED}Error: cargo not found. Please install Rust or copy a compiled 'bzod' binary to the current directory.${NC}"
# 2. Install Binary (safe atomic download)
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
ARCH="$(uname -m)"
case $ARCH in
x86_64) BINARY_NAME="bzod-x86_64-unknown-linux-gnu" ;;
aarch64|arm64) BINARY_NAME="bzod-aarch64-unknown-linux-gnu" ;;
armv7l) BINARY_NAME="bzod-armv7-unknown-linux-gnueabihf" ;;
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
esac
REPO="thakares/nx9-url-shortener"
RELEASE_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}"
TMP_BINARY=$(mktemp)
echo "Trying GitHub Releases..."
if curl --retry 5 --retry-delay 2 --retry-connrefused \
-L -f -o "${TMP_BINARY}" "${RELEASE_URL}" 2>/dev/null; then
echo -e "${GREEN}✓ Downloaded from GitHub Releases${NC}"
else
echo -e "${BLUE}GitHub Releases not available. Trying GHCR...${NC}"
if command -v docker >/dev/null 2>&1; then
TMP_GHCR=$(mktemp)
docker pull ghcr.io/${REPO}:latest >/dev/null 2>&1 || true
if docker run --rm --entrypoint cat ghcr.io/${REPO}:latest /usr/local/bin/bzod > "${TMP_GHCR}" 2>/dev/null && [ -s "${TMP_GHCR}" ]; then
mv "${TMP_GHCR}" "${TMP_BINARY}"
echo -e "${GREEN}✓ Extracted from GHCR${NC}"
fi
fi
if [ ! -s "${TMP_BINARY}" ]; then
echo -e "${BLUE}Falling back to local build...${NC}"
if ! command -v cargo >/dev/null 2>&1; then
echo -e "${RED}Neither pre-built binary nor cargo available.${NC}"
exit 1
fi
apt-get install -y pkg-config build-essential
cargo build --release
cp target/release/bzod "${TMP_BINARY}"
echo -e "${GREEN}✓ Built from source${NC}"
fi
fi
# Atomic replace with backup
if [ -f "${INSTALL_PATH}" ]; then
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
fi
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
# Verify
if [ ! -x "${INSTALL_PATH}" ]; then
echo -e "${RED}Binary installation failed${NC}"
exit 1
fi
cargo build --release
echo -e "${GREEN}Release build completed.${NC}"
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || {
echo -e "${RED}Binary verification failed${NC}"
exit 1
}
# 4. Install Binary
echo -e "\n${BLUE}[3/8] Installing binary to ${INSTALL_PATH}...${NC}"
cp target/release/bzod "${INSTALL_PATH}"
chmod 755 "${INSTALL_PATH}"
chown root:root "${INSTALL_PATH}"
echo -e "${GREEN}Binary installed successfully.${NC}"
# Show installed version
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
# 5. Create Dedicated locked-down System User
echo -e "\n${BLUE}[4/8] Creating dedicated system user '${SERVICE_USER}'...${NC}"
# 3. Create System User
echo -e "\n${BLUE}[3/8] Creating system user '${SERVICE_USER}'...${NC}"
if ! id -u "${SERVICE_USER}" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
echo -e "${GREEN}System user '${SERVICE_USER}' created.${NC}"
else
echo "User '${SERVICE_USER}' already exists."
fi
# 6. Configure Directory Trees and Permissions
echo -e "\n${BLUE}[5/8] Setting up configuration and data directories...${NC}"
mkdir -p "${CONFIG_DIR}"
mkdir -p "${DATA_DIR}"
# 4. Setup Directories
echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
chmod 700 "${CONFIG_DIR}"
# Copy .env file if it exists, otherwise prompt/generate
if [ -f .env ] && [ ! -f "${ENV_FILE}" ]; then
echo "Copying local .env file to ${ENV_FILE}..."
cp .env "${ENV_FILE}"
elif [ ! -f "${ENV_FILE}" ]; then
echo "Generating default configuration file at ${ENV_FILE}..."
# 5. Configuration (preserve on upgrades)
echo -e "\n${BLUE}[5/8] Configuration...${NC}"
if [ ! -f "${ENV_FILE}" ]; then
echo -e "${BLUE}Generating new secure configuration...${NC}"
cat <<EOF > "${ENV_FILE}"
HOST=0.0.0.0
PORT=8080
PORT=8654
DATA_DIR=${DATA_DIR}
COOKIE_SECURE=true
RUST_LOG=info
SESSION_SECRET=$(openssl rand -hex 32)
ADMIN_USERNAME=admin
# SHA-256 for bootstrap (Default: admin)
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
LINK_CHECK_INTERVAL_MINS=60
AGGREGATION_INTERVAL_MINS=60
DATA_RETENTION_DAYS=365
EOF
chmod 600 "${ENV_FILE}"
chown root:"${SERVICE_USER}" "${ENV_FILE}"
else
echo -e "${GREEN}Existing configuration preserved${NC}"
fi
chmod 600 "${ENV_FILE}"
chown -R root:"${SERVICE_USER}" "${CONFIG_DIR}"
chown -R "${SERVICE_USER}":"${SERVICE_USER}" /var/lib/bzod
echo -e "${GREEN}Directories and permission parameters configured.${NC}"
# 7. Initialise DB as the service user (avoids file permission conflicts)
echo -e "\n${BLUE}[6/8] Initialising databases...${NC}"
sudo -u "${SERVICE_USER}" "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}Databases initialised.${NC}"
# 8. Set Up Systemd Service
echo -e "\n${BLUE}[7/8] Installing systemd service unit...${NC}"
# 6. Systemd Service
echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}"
cat <<EOF > "${SYSTEMD_UNIT}"
[Unit]
Description=BZOD - Personal URL Shortener & Landing Page Platform
After=network.target
Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
@@ -107,11 +146,12 @@ User=${SERVICE_USER}
Group=${SERVICE_USER}
WorkingDirectory=/var/lib/bzod
EnvironmentFile=${ENV_FILE}
ExecStart=${INSTALL_PATH} serve --host 0.0.0.0 --port 8080 --data-dir ${DATA_DIR}
ExecStart=${INSTALL_PATH} serve
Restart=on-failure
RestartSec=5s
# Hardening / Sandboxing options for security
# Security Hardening
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
@@ -119,6 +159,10 @@ PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
LockPersonality=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/bzod
[Install]
@@ -127,21 +171,56 @@ EOF
chmod 644 "${SYSTEMD_UNIT}"
systemctl daemon-reload
echo -e "${GREEN}Systemd service registered.${NC}"
# 9. Enable and Start the Service
echo -e "\n${BLUE}[8/8] Starting BZOD service...${NC}"
systemctl enable bzod
systemctl restart bzod
# 7. Initialize & Start
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
sleep 2
if systemctl is-active --quiet bzod; then
echo -e "${GREEN}BZOD service is running successfully!${NC}"
echo -e "\n${BLUE}=== Deployment Completed Successfully ===${NC}"
echo -e "You can access BZOD at http://localhost:8080"
echo -e "Admin Login Dashboard is at http://localhost:8080/admin"
echo -e "System service logs: journalctl -u bzod -f"
echo -e "To change the default admin password, run: bzod create-admin --data-dir ${DATA_DIR}"
if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}✓ Databases initialized${NC}"
else
echo -e "${RED}Error: BZOD service failed to start. Check logs using: journalctl -u bzod -n 50${NC}"
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
fi
systemctl enable --now bzod
# 8. Validation + Rollback
sleep 3
if ! systemctl is-active --quiet bzod; then
echo -e "${RED}Service failed to start! Rolling back...${NC}"
if [ -f "${INSTALL_PATH}.bak" ]; then
install -m 755 "${INSTALL_PATH}.bak" "${INSTALL_PATH}"
systemctl restart bzod || true
fi
journalctl -u bzod -n 50 --no-pager
exit 1
fi
# Clean up backup on success
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
# Soft health check
if command -v curl >/dev/null 2>&1; then
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then
echo -e "${GREEN}✓ HTTP health check passed${NC}"
else
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
fi
fi
# Final Message
IP=$(hostname -I | awk '{print $1}' | head -n1)
echo -e "\n${GREEN}=== BZOD Deployed Successfully! ===${NC}"
echo -e "🌐 Web UI: http://${IP}:8654"
echo -e "🔑 Admin: http://${IP}:8654/admin"
echo -e "🖥 Architecture: ${ARCH}"
echo -e "📦 Version: ${VERSION}"
echo -e "\nNext step (first install):"
echo -e " sudo -u bzod bzod create-admin"
echo -e "\nCommands:"
echo -e " journalctl -u bzod -f"
echo -e " bzod doctor"
echo -e " systemctl status bzod"
echo -e "\n${GREEN}Enjoy your lightweight, privacy-first, self-hosted URL shortener!${NC}"
+54 -22
View File
@@ -1,28 +1,60 @@
name: app-bzod
services:
bzod:
build:
context: .
context: /DATA/AppData/bzod
dockerfile: Dockerfile
cpu_shares: 90
command: []
container_name: bzod
restart: unless-stopped
ports:
- "8654:8654"
volumes:
- /DATA/AppData/bzod/data:/app/data
- /DATA/AppData/bzod/config:/app/config
deploy:
resources:
limits:
memory: 31940M
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
COOKIE_SECURE: "false"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8654/status"]
interval: 30s
timeout: 5s
retries: 3
- COOKIE_SECURE=false
- DATA_DIR=/app/data
- HOST=0.0.0.0
- PORT=8654
- RUST_LOG=info
hostname: bzod
image: nx9-url-shortener:v0.4.0
ports:
- mode: ingress
target: 8654
published: "8654"
protocol: tcp
restart: unless-stopped
volumes:
- type: bind
source: /DATA/AppData/bzod/data
target: /app/data
bind:
create_host_path: true
- type: bind
source: /DATA/AppData/bzod/config
target: /app/config
bind:
create_host_path: true
- type: bind
source: /DATA/AppData/bzod/www
target: /app/www
devices: []
cap_add: []
networks:
- default
privileged: false
networks:
default:
name: app_default
x-casaos:
author: self
category: self
hostname: ""
icon: ""
index: /
is_uncontrolled: false
port_map: "8654"
scheme: http
title:
custom: nx9-url-shortener
+391
View File
@@ -0,0 +1,391 @@
# BZOD REST API
> Programmatic access to URLs, Landing Pages, QR Codes, Analytics, and Audit Logs.
## Overview
The BZOD REST API allows automation and integration with external systems such as:
* Home Assistant
* Shell Scripts
* CI/CD Pipelines
* Monitoring Systems
* Internal Applications
* Self-hosted Services
All API endpoints require authentication using an API Token generated from:
```text
Admin Dashboard → Settings → REST API Tokens
```
---
# Authentication
Generate an API token from the Admin Dashboard.
Example token:
```text
bzo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Pass the token using the `Authorization` header.
## Example
```bash
curl \
-H "Authorization: bzo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
https://your-domain.com/api/v1/stats
```
---
# Base URL
```text
https://your-domain.com/api/v1
```
Example:
```text
https://bzo.in/api/v1
```
---
# Response Format
Successful responses:
```json
{
"success": true,
"data": {}
}
```
Error responses:
```json
{
"success": false,
"error": "Invalid API token"
}
```
---
# URL Management
## List URLs
```http
GET /api/v1/urls
```
### Example
```bash
curl \
-H "Authorization: TOKEN" \
https://your-domain.com/api/v1/urls
```
---
## Create URL
```http
POST /api/v1/urls
```
### Request
```json
{
"code": "rust",
"target_url": "https://www.rust-lang.org",
"description": "Rust Language"
}
```
### Example
```bash
curl \
-X POST \
-H "Authorization: TOKEN" \
-H "Content-Type: application/json" \
-d '{
"code":"rust",
"target_url":"https://www.rust-lang.org"
}' \
https://your-domain.com/api/v1/urls
```
---
## Get URL
```http
GET /api/v1/urls/{uuid}
```
Example:
```http
GET /api/v1/urls/5d4d9e98-7cb7-4c97-9a0a-123456789abc
```
---
## Update URL
```http
PUT /api/v1/urls/{uuid}
```
---
## Delete URL
```http
DELETE /api/v1/urls/{uuid}
```
---
## URL Preview
```http
GET /api/v1/urls/{uuid}/preview
```
Returns rendered metadata used by preview cards.
---
# Landing Pages
## List Pages
```http
GET /api/v1/pages
```
---
## Create Page
```http
POST /api/v1/pages
```
### Example Request
```json
{
"title": "My Product",
"slug": "product",
"description": "Product Landing Page",
"content": "<h1>Hello World</h1>"
}
```
---
## Get Page
```http
GET /api/v1/pages/{uuid}
```
---
## Update Page
```http
PUT /api/v1/pages/{uuid}
```
---
## Delete Page
```http
DELETE /api/v1/pages/{uuid}
```
---
# Analytics
## Global Statistics
```http
GET /api/v1/stats
```
Returns overall platform metrics.
Example response:
```json
{
"total_urls": 125,
"total_pages": 12,
"total_clicks": 8431,
"total_qr_scans": 241
}
```
---
## URL Statistics
```http
GET /api/v1/stats/url/{uuid}
```
Returns analytics for a single URL.
---
## Landing Page Statistics
```http
GET /api/v1/stats/page/{uuid}
```
Returns analytics for a single landing page.
---
# QR Codes
## Download QR Code
```http
GET /api/v1/qr/{code}
```
Example:
```http
GET /api/v1/qr/rust
```
Returns QR image.
---
# Bulk Operations
## Bulk QR Export
```http
POST /api/v1/bulk/qr
```
Generate QR codes for multiple URLs.
---
## Bulk URL Operations
```http
POST /api/v1/bulk/url
```
Bulk create, update, or manage URLs.
---
# Audit Log
## List Audit Events
```http
GET /api/v1/audit
```
Returns administrative activity history.
Example response:
```json
[
{
"event": "url_created",
"user": "admin",
"timestamp": "2026-06-17T14:30:00Z"
}
]
```
---
# HTTP Status Codes
| Code | Description |
| ---- | --------------------- |
| 200 | Success |
| 201 | Created |
| 400 | Invalid Request |
| 401 | Authentication Failed |
| 403 | Access Denied |
| 404 | Resource Not Found |
| 409 | Conflict |
| 500 | Internal Server Error |
---
# Security Notes
* API tokens are displayed only once during creation.
* Tokens are stored as hashes and cannot be recovered.
* Revoke unused tokens immediately.
* Always use HTTPS.
* Never embed API tokens in public repositories.
---
# Example: Create URL From Shell Script
```bash
TOKEN="bzo_xxxxxxxxxxxxxxxxx"
curl \
-X POST \
-H "Authorization: ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"code":"example",
"target_url":"https://example.com"
}' \
https://your-domain.com/api/v1/urls
```
---
# API Stability
The BZOD API follows semantic versioning.
Current API namespace:
```text
/api/v1
```
Future breaking changes will be introduced under a new versioned namespace.
Example:
```text
/api/v2
```
+24
View File
@@ -0,0 +1,24 @@
# Changelog
## v0.4.0
### Added
* Raw visitor activity logs
* Analytics drill-down pages
* Date-range analytics filters
* CSV export
* JSON export
* Advanced pagination
* Visitor log tables
### Improved
* Registry pagination
* Analytics navigation
* Export performance
### Fixed
* Pagination edge cases
* Analytics sorting consistency
+382
View File
@@ -0,0 +1,382 @@
# BZOD vs Other Self-Hosted URL Shorteners
**BZOD (nx9-url-shortener)** is a modern, privacy-focused, self-hosted URL management platform built in Rust as part of the **NX9 Platform**.
Unlike many traditional URL shorteners that focus solely on redirects, BZOD combines:
* URL shortening
* Landing pages
* QR code generation
* QR analytics
* Password protection
* Link expiration
* REST API
* CLI automation
* Backup & restore
* Audit logging
into a single lightweight deployment.
**Philosophy:** *One binary. One command. Full ownership.*
---
## At a Glance
* Rust-based
* Single ~18 MB binary
* Embedded SQLite
* No external services required
* Landing pages
* QR generation & analytics
* Password-protected links
* REST API
* CLI automation
* Backup & restore
* Audit trail
* MIT OR Apache-2.0 licensed
* One-command deployment
---
## Quick Comparison
| Feature | BZOD | Shlink | YOURLS | Chhoto URL |
| --------------------- | ----------------- | -------- | -------- | ---------- |
| Language | Rust | PHP | PHP | Rust |
| Single Binary | ✅ | ❌ | ❌ | ✅ |
| Landing Pages | ✅ | ❌ | Plugin | ❌ |
| QR Code + Analytics | ✅ | Partial | Plugin | Partial |
| Password Protection | ✅ | Limited | Plugin | ❌ |
| Backup & Restore | ✅ | External | External | ❌ |
| Audit Trail | ✅ | Limited | Plugin | ❌ |
| CLI Tools | ✅ | Limited | Limited | Limited |
| Dependencies | None | PHP + DB | PHP + DB | None |
| Deployment Complexity | Low | Medium | High | Low |
| License | MIT OR Apache-2.0 | MIT | MIT | MIT |
---
## Design Philosophy
| Principle | BZOD |
| -------------------------- | ----------------- |
| Self-hosted | ✅ |
| Privacy-first | ✅ |
| Open Source | MIT OR Apache-2.0 |
| Vendor Lock-in | None |
| Telemetry | None |
| External Services Required | None |
| Database Server Required | No (SQLite) |
| Runtime Dependencies | None |
| Single Binary | Yes (~18 MB) |
| Linux-first | Yes |
---
## The NX9 Philosophy
BZOD is part of the **NX9 Platform**.
NX9 projects follow strict engineering principles:
* Linux-native first
* Rust-first
* Single binary deployments
* No NodeJS
* No React
* No Python runtime dependencies
* No vendor lock-in
* No telemetry
* Privacy-first by default
* MIT OR Apache-2.0 licensed
GitHub and Codeberg are source-code repositories, not the projects themselves.
The software is the project.
The goal of NX9 is simple:
> Build technology that serves people, organizations, communities, and governments — not advertising networks, data brokers, or vendor ecosystems.
---
## Why BZOD Exists
Most self-hosted URL shorteners optimize for one of two extremes:
### 1. Minimal Redirect Service
A tiny application that creates short links and redirects traffic.
Advantages:
* Extremely lightweight
* Easy to understand
* Easy to maintain
Disadvantages:
* Limited administration
* Limited analytics
* Limited security features
* Often requires additional tools
### 2. Large Multi-Service Platform
Feature-rich systems with extensive integrations and dependencies.
Advantages:
* Powerful analytics
* Advanced routing
* Large ecosystems
Disadvantages:
* More infrastructure
* More maintenance
* Higher resource requirements
### BZOD's Approach
BZOD intentionally sits in the middle.
It is:
* Small enough for a Raspberry Pi
* Powerful enough for organizations
* Simple enough for homelabs
* Complete enough for production use
---
# BZOD vs Go URL Shorteners
Popular Go projects include:
* Krtk
* Slash
* Goshorly
* Shortr
* Custom Gin/Echo implementations
## Detailed Comparison
| Aspect | BZOD (Rust) | Typical Go Projects |
| ------------------- | -------------------- | ------------------- |
| Binary | Single ~18 MB binary | Usually 10–20 MB |
| Runtime | None | None |
| Database | Embedded SQLite | SQLite / PostgreSQL |
| Landing Pages | ✅ Built-in | Rare |
| QR Generation | ✅ | Sometimes |
| QR Analytics | ✅ | Rare |
| Password Protection | ✅ | Varies |
| Link Expiry | ✅ | Often |
| One-Time Links | ✅ | Rare |
| UTM Builder | ✅ | Rare |
| REST API | ✅ | Usually |
| CLI | ✅ Extensive | Usually limited |
| Backup & Restore | ✅ Built-in | Rare |
| Audit Logs | ✅ | Rare |
| Admin Dashboard | ✅ | Varies |
### Summary
Go shorteners are often:
* Extremely simple
* Fast
* Easy to extend
BZOD focuses on:
* Rich built-in functionality
* Complete ownership
* Minimal operations
* Batteries-included deployment
---
# BZOD vs Python URL Shorteners
Popular Python projects include:
* Pygmy
* ReducePy
* Schort
* Flask/FastAPI examples
## Detailed Comparison
| Aspect | BZOD (Rust) | Python Solutions |
| ---------------- | --------------------- | ----------------- |
| Runtime | None | Python required |
| Deploy Size | ~18 MB | Often 100+ MB |
| Memory Usage | Very Low | Moderate |
| Landing Pages | ✅ | Rare |
| QR Analytics | ✅ | Rare |
| Backup & Restore | ✅ | Rare |
| CLI Tools | ✅ | Limited |
| Dashboard | ✅ | Varies |
| Security | Argon2id + Audit Logs | Project dependent |
| Performance | Excellent | Good |
### Summary
Python solutions are ideal when:
* Already using Python
* Rapid prototyping
* Easy customization
BZOD is ideal when:
* Long-term deployment matters
* Resource efficiency matters
* Minimal maintenance is desired
---
# BZOD vs Shlink
Shlink is one of the most mature self-hosted URL shorteners available.
## Detailed Comparison
| Aspect | BZOD | Shlink |
| ------------------------ | ------------- | ---------------- |
| Language | Rust | PHP |
| Deployment | Single binary | PHP stack |
| External DB | No | Usually yes |
| Landing Pages | ✅ | ❌ |
| Password Protected Links | ✅ | Limited |
| QR Analytics | ✅ | Partial |
| UTM Builder | ✅ | ❌ |
| Backup & Restore | ✅ | External tooling |
| Audit Trail | ✅ | Limited |
| Dynamic Redirect Rules | ❌ | ✅ |
| Multi-domain | Planned | ✅ |
| Ecosystem | Growing | Mature |
### Summary
Choose Shlink when:
* Multi-domain management is critical
* Dynamic redirect rules are required
* Enterprise-scale analytics matter
Choose BZOD when:
* Simplicity matters
* Privacy matters
* Minimal infrastructure matters
* Landing pages are important
---
# BZOD vs YOURLS
YOURLS is the classic self-hosted URL shortener.
## Detailed Comparison
| Aspect | BZOD | YOURLS |
| ------------- | ------------- | ---------- |
| Language | Rust | PHP |
| Architecture | Single binary | LAMP stack |
| Plugins | Not required | Extensive |
| Landing Pages | ✅ | Plugin |
| QR Analytics | ✅ | Plugin |
| Audit Logs | ✅ | Plugin |
| Backup Tools | ✅ | External |
| API | ✅ | ✅ |
| Maintenance | Minimal | Moderate |
### Summary
YOURLS wins on:
* Age
* Community
* Plugin ecosystem
BZOD wins on:
* Simplicity
* Deployment
* Modern architecture
* Integrated features
---
# BZOD vs Chhoto URL
Chhoto URL is the closest Rust-based competitor.
## Detailed Comparison
| Aspect | BZOD | Chhoto URL |
| ---------------- | ------ | ---------- |
| Language | Rust | Rust |
| Landing Pages | ✅ | ❌ |
| QR Codes | ✅ | ✅ |
| QR Analytics | ✅ | ❌ |
| Password Links | ✅ | ❌ |
| Backup & Restore | ✅ | ❌ |
| REST API | ✅ | JSON-RPC |
| Audit Logs | ✅ | ❌ |
| Analytics | Rich | Basic |
| Binary Size | ~18 MB | Smaller |
### Summary
Choose Chhoto URL for:
* Maximum simplicity
* Minimal footprint
Choose BZOD for:
* Feature completeness
* Better administration
* Better analytics
---
## Future Comparisons
Additional comparison sections may be added in the future for:
* Bitly
* Dub
* Pygmy
* Krtk
* Other self-hosted URL management platforms
---
## Conclusion
**BZOD is not merely a URL shortener.**
It is a lightweight URL management platform that combines:
* Link shortening
* Landing pages
* QR services
* Analytics
* Automation
* Security
* Backups
into a single deployable Rust binary.
As part of the NX9 Platform, BZOD follows a simple principle:
> Own your links. Own your data. Own your infrastructure.
No telemetry. No vendor lock-in. No unnecessary complexity.
For users seeking privacy, simplicity, ownership, and long-term sustainability, BZOD offers a compelling alternative to both cloud SaaS platforms and traditional self-hosted URL shorteners.
+545
View File
@@ -0,0 +1,545 @@
# Docker Deployment Guide for BZOD
This guide covers deployment, upgrades, backup, restore, troubleshooting, and production best practices for **BZOD (nx9-url-shortener)** using Docker.
---
# Overview
BZOD is a lightweight self-hosted URL shortener and landing page platform written in Rust.
Features include:
* URL shortening
* Human-readable custom slugs (`!office`, `!home`, etc.)
* Landing pages
* QR code generation
* Analytics
* Audit logging
* API access
* Backup and restore
* SQLite-based storage
* Docker deployment
BZOD is designed to remain simple:
* No PostgreSQL
* No Redis
* No external dependencies
* No vendor lock-in
---
# Quick Start
## Clone Repository
```bash
git clone https://github.com/thakares/nx9-url-shortener.git
cd nx9-url-shortener
```
## Build and Start
```bash
docker compose up -d --build
```
## Create Administrator
```bash
docker exec -it bzod bzod create-admin
```
Open:
```text
http://SERVER-IP:8654
```
Admin panel:
```text
http://SERVER-IP:8654/admin
```
---
# Docker Compose
Example:
```yaml
services:
bzod:
container_name: bzod
build: .
restart: unless-stopped
ports:
- "8654:8654"
volumes:
- ./data:/app/data
- ./config:/app/config
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
COOKIE_SECURE: "false"
healthcheck:
test: ["CMD", "./bzod", "doctor"]
interval: 30s
timeout: 10s
retries: 3
```
Start:
```bash
docker compose up -d
```
Verify:
```bash
docker ps
docker logs -f bzod
```
---
# Directory Layout
Typical deployment:
```text
bzod/
├── docker-compose.yml
├── Dockerfile
├── config/
├── data/
│ ├── admin.db
│ ├── content.db
│ ├── analytics.db
│ └── system.db
└── backups/
```
---
# Root Landing Page
BZOD can serve a static landing page from:
```text
www/index.html
```
This page is available at:
```text
https://your-domain/
```
Examples:
```text
https://bzo.in/
https://short.example.com/
```
The root landing page is packaged automatically inside the Docker image.
---
# Reverse Proxy Configuration
BZOD is intended to run behind a reverse proxy.
Example Nginx configuration:
```nginx
server {
server_name bzo.in;
location / {
proxy_pass http://127.0.0.1:8654;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Example deployment:
```text
Internet
↓
Nginx Proxy Manager
↓
BZOD Docker Container
```
---
# Environment Variables
| Variable | Description | Default |
| ------------- | ------------------ | --------- |
| HOST | Bind address | 0.0.0.0 |
| PORT | Listen port | 8654 |
| DATA_DIR | Database directory | /app/data |
| COOKIE_SECURE | Secure cookies | false |
| RUST_LOG | Logging level | info |
Production recommendation:
```text
COOKIE_SECURE=true
```
when HTTPS is enabled.
---
# Analytics Export
Analytics pages support:
* Raw visitor logs
* CSV export
* JSON export
* Date filtering
Exports can be generated from:
Admin → Analytics
Backup
---
## Web UI
Navigate to:
```text
Admin → Settings → Maintenance & DB Utilities
```
Click:
```text
Download Backup
```
A compressed archive containing all databases will be downloaded.
---
## CLI Backup
Create backup:
```bash
docker exec -it bzod bzod backup
```
Example output:
```text
backup-2026-06-14.tar.gz
```
---
# Restore
## Web UI Restore
Navigate to:
```text
Admin → Settings → Maintenance & DB Utilities
```
Upload:
```text
backup.tar.gz
```
Type:
```text
RESTORE
```
Confirm restore.
The system will:
1. Validate archive contents
2. Restore databases
3. Reinitialize database access
4. Redirect to login
---
## CLI Restore
Copy backup archive into container or mounted volume.
Run:
```bash
docker exec -it bzod bash
cd /app/data
bzod restore --file backup.tar.gz
```
---
# Disaster Recovery
Example recovery procedure:
```bash
docker compose down
# Restore backup archive
docker compose up -d
```
Verify:
```bash
docker exec -it bzod bzod doctor
docker exec -it bzod bzod validate
```
Check:
* URLs
* Landing pages
* Analytics
* Audit logs
* Settings
---
# Useful CLI Commands
Health:
```bash
docker exec -it bzod bzod doctor
```
Statistics:
```bash
docker exec -it bzod bzod stats
```
Validate databases:
```bash
docker exec -it bzod bzod validate
```
Create admin:
```bash
docker exec -it bzod bzod create-admin
```
Shorten URL:
```bash
docker exec -it bzod bzod shorten https://example.com
```
Custom slug:
```bash
docker exec -it bzod bzod shorten https://example.com --slug !office
```
Expand URL:
```bash
docker exec -it bzod bzod expand !office
```
---
# Upgrading
Pull latest source:
```bash
git pull
```
Rebuild:
```bash
docker compose build --no-cache
```
Restart:
```bash
docker compose up -d
```
Verify:
```bash
docker logs -f bzod
```
# Upgrading to v0.4.0
1. Backup databases
2. Pull latest source
3. Rebuild container
4. Restart service
```bash
git pull
docker compose build --no-cache
docker compose up -d
---
# Troubleshooting
## Read-Only SQLite Database
Symptoms:
```text
attempt to write a readonly database
```
Check ownership:
```bash
ls -lah data/
```
Fix permissions:
```bash
docker exec -u 0 -it bzod bash
chown -R bzod:bzod /app/data
```
docker exec -it bzod bzod doctor
Restart:
```bash
docker compose restart bzod
```
---
## Missing Root Landing Page
Symptoms:
```text
404 on /
```
Verify:
```bash
docker exec -it bzod ls -lah /app/www
```
Expected:
```text
/app/www/index.html
```
Rebuild image if necessary:
```bash
docker compose build --no-cache
docker compose up -d
```
---
## Health Check Failure
Inspect logs:
```bash
docker logs bzod
```
Run:
```bash
docker exec -it bzod bzod doctor
```
---
## Port Already In Use
Change host port mapping:
```yaml
ports:
- "8080:8654"
```
Access:
```text
http://SERVER-IP:8080
```
---
# Production Recommendations
* Use HTTPS
* Run behind Nginx Proxy Manager or Nginx
* Use strong administrator credentials
* Schedule regular backups
* Periodically test restore procedures
* Monitor disk space
* Keep Docker images updated
---
# Validation Checklist
After deployment verify:
* [ ] Admin login works
* [ ] URL shortening works
* [ ] Custom slugs work
* [ ] Landing pages work
* [ ] QR generation works
* [ ] Analytics recorded
* [ ] Backup download works
* [ ] Restore workflow works
* [ ] Root landing page loads
* [ ] `bzod doctor` reports healthy
A deployment should not be considered production-ready until backup and restore procedures have been successfully tested.
+434
View File
@@ -0,0 +1,434 @@
# TESTING.md
# BZOD Test Procedures
This document describes the official verification procedures for BZOD.
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.
---
# Philosophy
BZOD prioritizes:
1. Data Integrity
2. Operational Simplicity
3. Recovery Capability
4. Deployment Reproducibility
5. Functional Correctness
A passing unit test suite alone is insufficient.
A release is considered valid only if backup, restore, migration, and recovery procedures have been verified.
---
# Test Categories
## 1. Build Verification
Verify the application compiles successfully.
```bash
cargo check
cargo build
cargo build --release
```
Expected Result:
* No compiler errors
* No panics during startup
* Release binary generated successfully
---
## 2. Static Analysis
```bash
cargo fmt --check
cargo clippy --all-targets
```
Expected Result:
* Formatting passes
* No significant Clippy warnings
---
## 3. Unit Tests
```bash
cargo test
```
Expected Result:
* All tests pass
* No ignored critical tests
---
## 4. Database Initialization
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:
```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
```
Expected Result:
For every database:
```text
Integrity: ok
Foreign keys: enabled
Journal mode: wal
```
Final result:
```text
Overall status: HEALTHY
```
---
## 12. SQLite Integrity Checks
Manual verification.
```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:
```text
ok
```
for all databases.
---
## 13. Web Interface Verification
Start server.
```bash
./bzod serve
```
Verify:
* Homepage loads
* Redirects function
* Landing pages render
* Admin login works
* Dashboard loads
* API endpoints respond
---
## 14. Docker Verification
Build image.
```bash
docker compose build --no-cache
```
Start service.
```bash
docker compose up -d
```
Verify:
```bash
docker compose logs -f
```
Expected Result:
```text
Listening for requests
```
Verify:
```bash
./bzod doctor
```
inside container.
---
## 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.
---
# Guiding Principle
A successful release is not merely one that starts.
A successful release is one that can be recovered.
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+63 -20
View File
@@ -1,6 +1,6 @@
use rusqlite::{Connection, params};
use crate::db::analytics::{clean_referrer, parse_ua};
use rusqlite::{params, Connection};
use std::collections::HashMap;
use crate::db::analytics::{parse_ua, clean_referrer};
// Run aggregation for a specific day
pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> {
@@ -46,7 +46,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
let country = if v.country.is_empty() {
"Unknown".to_string()
} else {
v.country.clone()
};
let targets = vec![
(v.target_type.clone(), v.target_id.clone()),
@@ -55,22 +59,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for (t_type, t_id) in targets {
// Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"clicks".to_string(),
"".to_string(),
))
.or_insert(0) += 1;
// Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"country".to_string(),
country.clone(),
))
.or_insert(0) += 1;
// Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"browser".to_string(),
browser.clone(),
))
.or_insert(0) += 1;
// OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
*aggregates
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
.or_insert(0) += 1;
// Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"device".to_string(),
device.clone(),
))
.or_insert(0) += 1;
// Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"referrer".to_string(),
referrer.clone(),
))
.or_insert(0) += 1;
}
}
@@ -78,7 +119,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let tx = conn.transaction()?;
{
// Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
tx.execute(
"DELETE FROM daily_summaries WHERE date = ?1;",
params![date],
)?;
let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
@@ -86,14 +130,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
)?;
for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
}
}
tx.commit()?;
@@ -108,7 +145,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
tx.execute(
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
params![year_month],
)?;
tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -125,7 +165,10 @@ pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> ru
pub fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
tx.execute(
"DELETE FROM yearly_summaries WHERE year = ?1;",
params![year],
)?;
tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum AnalyticsEvent {
+6 -6
View File
@@ -1,10 +1,10 @@
pub mod aggregate;
pub mod events;
pub mod location;
pub mod queue;
pub mod worker;
pub mod location;
pub mod events;
pub mod aggregate;
pub use queue::AnalyticsQueue;
pub use location::get_client_country;
pub use events::AnalyticsEvent;
pub use aggregate::{aggregate_day, aggregate_month_from_daily, aggregate_year_from_daily};
pub use events::AnalyticsEvent;
pub use location::get_client_country;
pub use queue::AnalyticsQueue;
+2 -2
View File
@@ -1,6 +1,6 @@
use tokio::sync::mpsc;
use crate::models::VisitRecord;
use crate::db::Db;
use crate::models::VisitRecord;
use tokio::sync::mpsc;
#[derive(Clone)]
pub struct AnalyticsQueue {
+45 -9
View File
@@ -1,11 +1,11 @@
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::{interval, MissedTickBehavior};
use std::time::Duration;
use tracing::{info, error};
use tracing::{error, info};
use crate::db::analytics::insert_visits_batch;
use crate::db::Db;
use crate::models::VisitRecord;
use crate::db::analytics::insert_visits_batch;
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
let mut batch = Vec::new();
@@ -46,11 +46,47 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
return;
}
info!("Flushing {} visits to analytics database", batch.len());
let mut conn_lock = db.analytics.lock().unwrap();
if let Err(e) = insert_visits_batch(&mut conn_lock, batch) {
error!("Failed to write analytics batch to database: {:?}", e);
} else {
batch.clear();
info!(
"Flushing {} visits to user analytics databases",
batch.len()
);
// Group visits by owner_user_id
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> =
std::collections::HashMap::new();
for record in batch.drain(..) {
let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1)
groups.entry(user_id).or_default().push(record);
}
for (user_id, user_visits) in groups {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("analytics.db");
if let Some(parent) = db_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match rusqlite::Connection::open(&db_path) {
Ok(mut conn) => {
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
if let Err(e) = insert_visits_batch(&mut conn, &user_visits) {
error!(
"Failed to write analytics batch to user {} database: {:?}",
user_id, e
);
}
}
Err(e) => {
error!(
"Failed to open analytics database for user {}: {:?}",
user_id, e
);
}
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
// Deterministic CSRF token derived from session token
pub fn generate_csrf_token(session_id: &str) -> String {
+11 -9
View File
@@ -1,13 +1,13 @@
use crate::auth::session::authenticate_api_key;
use crate::models::ApiActor;
use crate::state::AppState;
use axum::{
extract::{FromRequestParts, FromRef},
extract::{FromRef, FromRequestParts},
http::{request::Parts, StatusCode},
};
use crate::state::AppState;
use crate::models::User;
use crate::auth::session::authenticate_api_key;
// Extractor: Authenticate API requests using Bearer token
pub struct ApiUser(pub User);
pub struct ApiUser(pub ApiActor);
#[axum::async_trait]
impl<S> FromRequestParts<S> for ApiUser
@@ -19,14 +19,16 @@ where
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let auth_header = parts.headers
let auth_header = parts
.headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
let conn = app_state.admin_db.lock().unwrap();
match authenticate_api_key(&conn, auth_header) {
Ok(Some(user)) => Ok(ApiUser(user)),
let admin_conn = app_state.admin_db.lock().unwrap();
let users_conn = app_state.users_db.lock().unwrap();
match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
Ok(Some(actor)) => Ok(ApiUser(actor)),
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
}
+6 -4
View File
@@ -1,9 +1,11 @@
pub mod password;
pub mod session;
pub mod csrf;
pub mod middleware;
pub mod password;
pub mod session;
pub use password::{hash_password, verify_password, verify_sha256};
pub use session::{generate_token, authenticate_session, authenticate_api_key};
pub use csrf::{generate_csrf_token, verify_csrf};
pub use middleware::ApiUser;
pub use password::{hash_password, verify_password, verify_sha256};
pub use session::{
authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token,
};
+7 -3
View File
@@ -1,21 +1,25 @@
use sha2::{Sha256, Digest};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use sha2::{Digest, Sha256};
// Hashing password with Argon2id
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(password_hash)
}
// Verifying Argon2id password hash
pub fn verify_password(password: &str, hash: &str) -> bool {
if let Ok(parsed_hash) = PasswordHash::new(hash) {
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
} else {
false
}
+259 -19
View File
@@ -1,10 +1,12 @@
use sha2::{Sha256, Digest};
use rand::{RngCore, thread_rng};
use crate::db::admin::{
get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used,
};
use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession};
use axum_extra::extract::CookieJar;
use rusqlite::Connection;
use chrono::Utc;
use crate::db::admin::{get_session, get_user_by_id, update_api_key_last_used, get_api_key_by_hash};
use crate::models::User;
use rand::{thread_rng, RngCore};
use rusqlite::{Connection, OptionalExtension};
use sha2::{Digest, Sha256};
// Generate a secure random token (hex-encoded)
pub fn generate_token(bytes_len: usize) -> String {
@@ -13,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
hex::encode(key)
}
// Authenticate session from cookies
pub fn authenticate_session(
// Authenticate administrator session from cookies
pub fn authenticate_admin_session(
conn: &Connection,
jar: &CookieJar,
) -> Result<Option<(User, String)>, rusqlite::Error> {
@@ -24,7 +26,33 @@ pub fn authenticate_session(
};
let session_id = cookie.value();
let session = match get_session(conn, session_id)? {
let session_opt: Option<AdminSession> = conn
.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
[session_id],
|row| {
let id: String = row.get(0)?;
// `user_id` may be stored as integer (users.db) or text (admin.db UUID).
let user_id_str: String = match row.get::<_, String>(1) {
Ok(s) => s,
Err(_) => {
let i: i64 = row.get(1)?;
i.to_string()
}
};
let expires_at: String = row.get(2)?;
let created_at: String = row.get(3)?;
Ok(AdminSession {
id,
user_id: user_id_str,
expires_at,
created_at,
})
},
)
.optional()?;
let session = match session_opt {
Some(s) => s,
None => return Ok(None),
};
@@ -39,19 +67,171 @@ pub fn authenticate_session(
return Ok(None);
}
// Get user
if let Some(user) = get_user_by_id(conn, &session.user_id)? {
// Get user (status must be 'active' and account_type = 'admin')
// If the session user_id looks numeric, bind as integer when querying users.db
// Try the extended lookup but catch errors (e.g., missing columns in legacy admin DB)
// Try the extended lookup; if it errors (legacy schema), perform a fallback lookup.
let (user_opt, extended_failed) = match if let Ok(id_i64) = session.user_id.parse::<i64>() {
conn.query_row(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
[id_i64],
|row| {
let id_str = row.get::<_, i64>(0)?.to_string();
Ok(User {
id: id_str,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
} else {
conn.query_row(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
[session.user_id.as_str()],
|row| {
// admin DB stores UUID string ids, so read as String
let id_str: String = row.get(0)?;
Ok(User {
id: id_str,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
} {
Ok(opt) => (opt, false),
Err(_) => (None, true),
};
if let Some(user) = user_opt {
return Ok(Some((user, session.id)));
}
if extended_failed {
// Fallback for legacy admin.db schemas which may not have `status`/`account_type` columns
// Try a simpler lookup by id only.
let fallback_user_opt = if let Ok(id_i64) = session.user_id.parse::<i64>() {
conn.query_row(
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
[id_i64],
|row| {
Ok(User {
id: row.get::<_, i64>(0)?.to_string(),
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
.unwrap_or(None)
} else {
conn.query_row(
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
[session.user_id.as_str()],
|row| {
Ok(User {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
.unwrap_or(None)
};
if let Some(user) = fallback_user_opt {
Ok(Some((user, session.id)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
// Authenticate user session from cookies
pub fn authenticate_user_session(
users_conn: &Connection,
jar: &CookieJar,
) -> Result<Option<(TenantUser, String)>, rusqlite::Error> {
let cookie = match jar.get("bzod_user_session") {
Some(c) => c,
None => return Ok(None),
};
let session_id = cookie.value();
// Get session from sessions table in users.db
let mut stmt = users_conn
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let session_opt: Option<UserSession> = stmt
.query_row([session_id], |row| {
Ok(UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
let session = match session_opt {
Some(s) => s,
None => return Ok(None),
};
// Check expiration
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
if expires.with_timezone(&Utc) < Utc::now() {
return Ok(None);
}
} else {
return Ok(None);
}
// Get tenant user (status must be 'active')
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([session.user_id], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})
.optional()?;
if let Some(user) = user_opt {
Ok(Some((user, session.id)))
} else {
Ok(None)
}
}
// Authenticate API key from Authorization header
// Authenticate API key/token from Authorization header (unified)
pub fn authenticate_api_key(
conn: &Connection,
admin_conn: &Connection,
users_conn: &Connection,
auth_header: &str,
) -> Result<Option<User>, rusqlite::Error> {
) -> Result<Option<ApiActor>, rusqlite::Error> {
if !auth_header.starts_with("Bearer ") {
return Ok(None);
}
@@ -66,13 +246,73 @@ pub fn authenticate_api_key(
hasher.update(key.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
// Update last used timestamp
update_api_key_last_used(conn, &api_key_rec.id)?;
// 1. Check user API tokens in users.db
let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?;
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
// Get user
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
return Ok(Some(user));
if let Some(user_id) = user_id_opt {
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([user_id], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})
.optional()?;
if let Some(user) = user_opt {
return Ok(Some(ApiActor::User(user)));
}
}
// 2. Check admin system API keys in admin.db
if let Some(api_key_rec) = get_api_key_by_hash(admin_conn, &hashed_key)? {
// Update last used timestamp
update_api_key_last_used(admin_conn, &api_key_rec.id)?;
// Get admin user from users.db (users_conn)
// Try to interpret the api_key user_id as an integer referencing users.db
if let Ok(user_id_i64) = api_key_rec.user_id.parse::<i64>() {
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
)?;
let user_opt = stmt
.query_row([user_id_i64], |row| {
let id_i64: i64 = row.get(0)?;
Ok(User {
id: id_i64.to_string(),
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
if let Some(user) = user_opt {
return Ok(Some(ApiActor::Admin(user)));
}
}
// Fallback: admin DB may store users with string UUIDs. Try looking up directly in admin_conn.
if let Ok(Some(admin_user)) = get_admin_user_by_id(admin_conn, &api_key_rec.user_id) {
return Ok(Some(ApiActor::Admin(User {
id: admin_user.id,
username: admin_user.username,
password_hash: admin_user.password_hash,
created_at: admin_user.created_at,
})));
}
}
+15 -3
View File
@@ -37,7 +37,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
// Coordinates calculations
let count = data.len();
let step_x = if count > 1 { chart_w / (count - 1) as f64 } else { chart_w };
let step_x = if count > 1 {
chart_w / (count - 1) as f64
} else {
chart_w
};
let mut points = Vec::new();
for (i, &(_, val)) in data.iter().enumerate() {
@@ -70,7 +74,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
for (i, (label, _)) in data.iter().enumerate() {
if i % label_step == 0 || i == count - 1 {
let x = points[i].0;
let short_label = if label.len() == 10 { &label[5..] } else { label };
let short_label = if label.len() == 10 {
&label[5..]
} else {
label
};
x_labels.push_str(&format!(
r##"<text x="{}" y="{}" fill="{}" font-size="11" font-family="system-ui, sans-serif" text-anchor="middle">{}</text>"##,
x, height - 15.0, DEFAULT_TEXT_COLOR, short_label
@@ -78,7 +86,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
x_labels.push_str(&format!(
r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>"##,
x, pad_top + chart_h, x, pad_top + chart_h + 5.0, DEFAULT_GRID_COLOR
x,
pad_top + chart_h,
x,
pad_top + chart_h + 5.0,
DEFAULT_GRID_COLOR
));
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
pub mod svg;
pub mod line;
pub mod bar;
pub mod line;
pub mod pie;
pub mod svg;
pub mod timeseries;
pub use line::generate_line_chart;
pub use bar::generate_bar_chart;
pub use line::generate_line_chart;
pub use pie::generate_pie_chart;
pub use timeseries::generate_timeseries_chart;
+8 -4
View File
@@ -1,16 +1,20 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
use crate::jobs::backup::perform_backup;
use std::path::PathBuf;
use tracing::info;
pub async fn run(
out: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(o) = out { config.backup_dir = PathBuf::from(o); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
if let Some(o) = out {
config.backup_dir = PathBuf::from(o);
}
// Init DB connections to ensure databases exist and migrate if needed
let db = Db::init(&config)?;
+150
View File
@@ -0,0 +1,150 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::fs::File;
use std::path::{Path, PathBuf};
use tar::{Builder, Header};
use tracing::{error, info};
use zstd::Encoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
username: String,
out: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let username_clean = username.trim().to_lowercase();
// 1. Get user details from users.db
let user_details = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_username(&conn, &username_clean)?
};
let user = match user_details {
Some(u) => u,
None => {
error!("User '{}' not found", username_clean);
return Ok(());
}
};
let user_id = user.id;
// 2. Fetch user's quotas
let quotas = {
let conn = db.users.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb FROM quotas WHERE user_id = ?1;",
[user_id],
|row| {
Ok(UserBackupQuotas {
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
})
}
)?
};
// 3. Define output path
let tar_path = match out {
Some(p) => PathBuf::from(p),
None => {
if !config.backup_dir.exists() {
std::fs::create_dir_all(&config.backup_dir)?;
}
config.backup_dir.join(format!(
"{}-{}.tar.zst",
username_clean,
Utc::now().format("%Y%m%d")
))
}
};
info!(
"Backing up user {} (ID: {}) to {:?}",
username_clean, user_id, tar_path
);
// 4. Force checkpoint on user's databases
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("analytics.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("profile.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
// 5. Create tar.zst archive
let file = File::create(&tar_path)?;
let zst_enc = Encoder::new(file, 3)?;
let mut tar = Builder::new(zst_enc);
// Write metadata.json directly into tar
let metadata_obj = UserBackupMetadata {
id: user.id,
username: user.username,
password_hash: user.password_hash,
status: user.status,
created_at: user.created_at,
account_type: user.account_type,
metadata: user.metadata,
quotas,
};
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
let mut header = Header::new_gnu();
header.set_size(metadata_bytes.len() as u64);
header.set_path("metadata.json")?;
header.set_mode(0o644);
header.set_cksum();
tar.append(&header, &metadata_bytes[..])?;
// Append database files
let mut append_file =
|name_in_archive: &str, path_on_disk: &Path| -> Result<(), Box<dyn std::error::Error>> {
if path_on_disk.exists() {
let mut file = File::open(path_on_disk)?;
tar.append_file(name_in_archive, &mut file)?;
}
Ok(())
};
append_file("content.db", &user_dir.join("content.db"))?;
append_file("analytics.db", &user_dir.join("analytics.db"))?;
append_file("profile.db", &user_dir.join("profile.db"))?;
tar.into_inner()?.finish()?;
info!("User backup generated successfully at {:?}", tar_path);
Ok(())
}
+14 -8
View File
@@ -1,16 +1,18 @@
use std::path::PathBuf;
use std::io::{self, Write};
use tracing::{info, error};
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use crate::auth::hash_password;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
username: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let final_username = match username {
@@ -30,9 +32,13 @@ pub async fn run(
}
let hash = hash_password(&password).map_err(|e| e.to_string())?;
let conn = db.admin.lock().unwrap();
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?;
info!("Successfully created admin user: {} (ID: {})", u.username, u.id);
let conn = db.users.lock().unwrap();
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
db.init_user_databases(u.id)?;
info!(
"Successfully created admin user: {} (ID: {})",
u.username, u.id
);
Ok(())
}
+86
View File
@@ -0,0 +1,86 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
username: Option<String>,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let final_username = match username {
Some(u) => u,
None => read_input("Enter username: "),
};
let username_clean = final_username.trim().to_lowercase();
if username_clean.is_empty() {
error!("Username cannot be empty");
return Ok(());
}
if username_clean.len() < 3 {
error!("Username must be at least 3 characters");
return Ok(());
}
if !username_clean
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
error!("Username must contain only alphanumeric characters, hyphens, or underscores");
return Ok(());
}
let final_password = match password {
Some(p) => p,
None => read_input("Enter password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
// Check if user already exists
{
let conn = db.users.lock().unwrap();
if crate::db::users::get_user_by_username(&conn, &username_clean)?.is_some() {
error!("User already exists: {}", username_clean);
return Ok(());
}
}
// Create user in DB (this seeds default quotas too)
let new_user = {
let conn = db.users.lock().unwrap();
crate::db::users::create_user(&conn, &username_clean, &hash, "standard", None)?
};
// Initialize their user specific directory and DB files (content.db, analytics.db, profile.db)
db.init_user_databases(new_user.id)?;
info!(
"Successfully created standard user: {} (ID: {})",
new_user.username, new_user.id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+92
View File
@@ -0,0 +1,92 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
force: bool,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
if user_id == 1 && !force {
error!("Deleting legacy_admin system account requires --force flag");
return Ok(());
}
// Capture user details
let user_details = {
let conn = db.users.lock().unwrap();
match crate::db::users::get_user_by_id(&conn, user_id)? {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?;
// Get all slugs owned by the user
let slugs: Vec<String> = {
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
let rows = stmt.query_map([user_id], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
// Delete from global_slugs and write to history
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
tx.commit()?;
}
// 2. Delete user folder and database files from disk
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
{
let conn = db.users.lock().unwrap();
crate::db::users::delete_user(&conn, user_id)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DELETION",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user_details.username)),
);
}
info!(
"Successfully deleted user {} (ID: {}) and all associated content",
user_details.username, user_id
);
Ok(())
}
+55
View File
@@ -0,0 +1,55 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "disabled" {
info!("User {} is already disabled", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "disabled")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DISABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!("User {} (ID: {}) has been disabled", user.username, user_id);
Ok(())
}
+30 -20
View File
@@ -1,8 +1,8 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::sqlite;
use rusqlite::Connection;
use std::path::PathBuf;
use tracing::info;
/// Run comprehensive database diagnostics.
///
@@ -12,7 +12,9 @@ pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
info!("Running BZOD database diagnostics...");
println!("BZOD Database Doctor");
@@ -35,27 +37,35 @@ pub async fn run(
}
match Connection::open(&db_path) {
Ok(conn) => {
match sqlite::collect_health_report(&conn, db_name) {
Ok(report) => {
println!("Database: {}", report.database);
println!(" Path: {:?}", db_path);
println!(" Schema version: {}", report.schema_version);
println!(" Journal mode: {}", report.journal_mode);
println!(" Foreign keys: {}", if report.foreign_keys_enabled { "enabled" } else { "DISABLED" });
println!(" Integrity: {}", if report.integrity_ok { "ok" } else { "FAILED" });
if !report.integrity_ok || !report.foreign_keys_enabled {
all_healthy = false;
Ok(conn) => match sqlite::collect_health_report(&conn, db_name) {
Ok(report) => {
println!("Database: {}", report.database);
println!(" Path: {:?}", db_path);
println!(" Schema version: {}", report.schema_version);
println!(" Journal mode: {}", report.journal_mode);
println!(
" Foreign keys: {}",
if report.foreign_keys_enabled {
"enabled"
} else {
"DISABLED"
}
}
Err(e) => {
println!("Database: {}", db_name);
println!(" Status: ERROR collecting health report: {}", e);
);
println!(
" Integrity: {}",
if report.integrity_ok { "ok" } else { "FAILED" }
);
if !report.integrity_ok || !report.foreign_keys_enabled {
all_healthy = false;
}
}
}
Err(e) => {
println!("Database: {}", db_name);
println!(" Status: ERROR collecting health report: {}", e);
all_healthy = false;
}
},
Err(e) => {
println!("Database: {}", db_name);
println!(" Status: FAILED to open: {}", e);
+58
View File
@@ -0,0 +1,58 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "active" {
info!("User {} is already active", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "active")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_ENABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"User {} (ID: {}) has been enabled (active)",
user.username, user_id
);
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
code: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let normalized_code = code.trim().to_lowercase();
if !crate::utils::validation::validate_redirect_code(&normalized_code) {
return Err("Invalid short code or custom slug format".into());
}
let url_opt = {
let conn = db.content.lock().unwrap();
crate::db::content::get_url_by_code(&conn, &normalized_code)?
};
match url_opt {
Some(url) => {
println!("{}", url.destination);
Ok(())
}
None => Err(format!("Short code not found: {}", normalized_code).into()),
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let users = {
let conn = db.users.lock().unwrap();
crate::db::users::list_users(&conn)?
};
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
"ID", "Username", "Status", "Type", "Created At"
);
println!(
"{:-<6}-+-{:-<20}-+-{:-<10}-+-{:-<12}-+-{:-<24}",
"", "", "", "", ""
);
for u in users {
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
u.id, u.username, u.status, u.account_type, u.created_at
);
}
Ok(())
}
+5 -3
View File
@@ -1,14 +1,16 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::info;
pub async fn run(
data_dir: Option<String>,
dry_run: bool,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
if dry_run {
info!("Dry run enabled: pending database migrations will be reported but not applied.");
+98 -5
View File
@@ -1,13 +1,24 @@
use clap::{Parser, Subcommand};
pub mod serve;
pub mod backup;
pub mod restore;
pub mod migrate;
pub mod stats;
pub mod validate;
pub mod create_admin;
pub mod doctor;
pub mod expand;
pub mod migrate;
pub mod restore;
pub mod serve;
pub mod shorten;
pub mod stats;
pub mod validate;
pub mod backup_user;
pub mod create_user;
pub mod delete_user;
pub mod disable_user;
pub mod enable_user;
pub mod list_users;
pub mod reset_password;
pub mod restore_user;
#[derive(Parser)]
#[command(name = "bzod")]
@@ -72,4 +83,86 @@ pub enum Commands {
#[arg(long)]
data_dir: Option<String>,
},
/// Shorten a URL (Feature 3)
Shorten {
/// The destination URL to shorten
target_url: String,
/// Custom slug (starting with ! followed by a-z, 0-9, -, _)
#[arg(long)]
slug: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Expand a shortened code or custom slug to its destination URL (Feature 4)
Expand {
/// The short code or custom slug to expand
code: String,
#[arg(long)]
data_dir: Option<String>,
},
/// Create a new standard user in the database
CreateUser {
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Delete a standard user and all their databases/slugs
DeleteUser {
/// User ID to delete
user_id: i64,
/// Force deletion of system account/legacy_admin
#[arg(long)]
force: bool,
#[arg(long)]
data_dir: Option<String>,
},
/// Disable a standard user
DisableUser {
/// User ID to disable
user_id: i64,
#[arg(long)]
data_dir: Option<String>,
},
/// Enable a standard user
EnableUser {
/// User ID to enable
user_id: i64,
#[arg(long)]
data_dir: Option<String>,
},
/// Reset standard user's password
ResetPassword {
/// User ID to reset
user_id: i64,
#[arg(long)]
password: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// List all standard/system users
ListUsers {
#[arg(long)]
data_dir: Option<String>,
},
/// Backup a standard user's databases to a .tar.zst package
BackupUser {
/// Username to backup
username: String,
/// Output .tar.zst filepath
#[arg(long)]
out: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Restore a standard user's databases from a .tar.zst package
RestoreUser {
/// Input .tar.zst package path
#[arg(long, required = true)]
file: String,
#[arg(long)]
data_dir: Option<String>,
},
}
+75
View File
@@ -0,0 +1,75 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
let final_password = match password {
Some(p) => p,
None => read_input("Enter new password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
{
let conn = db.users.lock().unwrap();
crate::db::users::reset_user_password(&conn, user_id, &hash)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_PASSWORD_RESET",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"Password for user {} (ID: {}) has been reset successfully",
user.username, user_id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+52 -10
View File
@@ -1,17 +1,59 @@
use std::path::PathBuf;
use crate::config::Config;
use flate2::read::GzDecoder;
use std::fs::File;
use std::io::{self, Write};
use tracing::{info, error};
use flate2::read::GzDecoder;
use std::path::PathBuf;
use tar::Archive;
use crate::config::Config;
use tracing::{error, info};
pub fn perform_restore(
file_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Open the archive
let f = File::open(file_path)?;
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;
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 !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());
}
// 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)?;
Ok(())
}
pub async fn run(
file: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let file_path = PathBuf::from(file);
if !file_path.exists() {
@@ -19,7 +61,10 @@ pub async fn run(
return Ok(());
}
info!("WARNING: Restoring will overwrite existing databases in {:?}", config.data_dir);
info!(
"WARNING: Restoring will overwrite existing databases in {:?}",
config.data_dir
);
print!("Are you sure you want to restore? (y/N): ");
let _ = io::stdout().flush();
let mut confirm = String::new();
@@ -35,10 +80,7 @@ pub async fn run(
}
info!("Restoring backup from: {:?}", file_path);
let f = File::open(&file_path)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
archive.unpack(&config.data_dir)?;
perform_restore(&file_path, &config.data_dir)?;
info!("Database files successfully restored.");
Ok(())
+269
View File
@@ -0,0 +1,269 @@
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;
use tracing::{error, info};
use zstd::Decoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
file: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let file_path = PathBuf::from(file);
if !file_path.exists() {
error!("Backup file not found: {:?}", file_path);
return Ok(());
}
let db = Db::init(&config)?;
// 1. Read metadata.json from the tar.zst archive
let f = File::open(&file_path)?;
let zst_dec = Decoder::new(f)?;
let mut archive = Archive::new(zst_dec);
let mut metadata_opt: Option<UserBackupMetadata> = None;
for entry_res in archive.entries()? {
let mut entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "metadata.json" {
let meta: UserBackupMetadata = serde_json::from_reader(&mut entry)?;
metadata_opt = Some(meta);
break;
}
}
let metadata = match metadata_opt {
Some(m) => m,
None => {
error!("Archive is missing metadata.json");
return Ok(());
}
};
info!("Restoring user {} from backup...", metadata.username);
// 2. Resolve target user ID and upsert user record in users.db
let target_user_id = {
let users_conn = db.users.lock().unwrap();
let existing_user =
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
match existing_user {
Some(u) => {
users_conn.execute(
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;",
rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id],
)?;
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
)?;
u.id
}
None => {
let id_taken: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[metadata.id],
|row| row.get(0),
)
.unwrap_or(false);
let new_id = if !id_taken {
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
)?;
metadata.id
} else {
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
)?;
users_conn.last_insert_rowid()
};
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
)?;
new_id
}
}
};
// 3. Extract database files to /data/users/<target_user_id>/
let dest_dir = config
.data_dir
.join("users")
.join(target_user_id.to_string());
std::fs::create_dir_all(&dest_dir)?;
let f2 = File::open(&file_path)?;
let zst_dec2 = Decoder::new(f2)?;
let mut archive2 = Archive::new(zst_dec2);
for entry_res in archive2.entries()? {
let mut entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match file_name {
"content.db" => {
let mut out_file = File::create(dest_dir.join("content.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
"analytics.db" => {
let mut out_file = File::create(dest_dir.join("analytics.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
"profile.db" => {
let mut out_file = File::create(dest_dir.join("profile.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
_ => {}
}
}
// 4. Register slugs in global_slugs
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
{
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],
)?;
// 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<i64> = 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<i64> = 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
crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(),
target_user_id,
&restored_content_conn,
)?;
info!(
"User '{}' (ID: {}) successfully restored from backup.",
metadata.username, target_user_id
);
Ok(())
}
+37 -8
View File
@@ -1,11 +1,11 @@
use crate::analytics::AnalyticsQueue;
use crate::config::Config;
use crate::db::Db;
use crate::state::AppState;
use crate::web::create_router;
use std::path::PathBuf;
use std::time::Instant;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
use crate::analytics::AnalyticsQueue;
use crate::state::AppState;
use crate::web::create_router;
pub async fn run(
host: Option<String>,
@@ -13,9 +13,15 @@ pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(h) = host { config.host = h; }
if let Some(p) = port { config.port = p; }
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(h) = host {
config.host = h;
}
if let Some(p) = port {
config.port = p;
}
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
info!("Starting BZOD server on {}:{}", config.host, config.port);
info!("Database directory: {:?}", config.data_dir);
@@ -52,11 +58,34 @@ pub async fn run(
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
});
let expiry_db = db.clone();
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db).await;
});
let reconcile_db = db.clone();
let reconcile_interval_hours = {
let conn = db.system.lock().unwrap();
conn.query_row(
"SELECT value FROM settings WHERE key = 'quota_reconcile_interval_hours';",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|val| val.parse::<u64>().ok())
.unwrap_or(24)
};
tokio::spawn(async move {
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
});
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.clone(),
system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
db: db.clone(),
config: config.clone(),
analytics_queue: queue,
+73
View File
@@ -0,0 +1,73 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
target_url: String,
slug: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Basic URL validation
if reqwest::Url::parse(&target_url).is_err() {
return Err("Invalid destination URL format".into());
}
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// 2. Validate/normalize slug/code
let code = match slug {
Some(s) => {
let normalized = s.trim().to_lowercase();
if !crate::utils::validation::validate_custom_slug(&normalized) {
return Err(
"Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _"
.into(),
);
}
normalized
}
None => crate::utils::random::generate_token(3),
};
// 3. Persist URL
let conn = db.content.lock().unwrap();
let res = crate::db::content::create_url_extended(
&conn,
&code,
&target_url,
None,
None,
&[],
None,
None,
None,
);
match res {
Ok(_) => {
let proto = if config.cookie_secure {
"https"
} else {
"http"
};
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, config.port));
// Output only the shortened URL as requested
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) => Err(e.into()),
}
}
+14 -4
View File
@@ -1,12 +1,14 @@
use std::path::PathBuf;
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
println!("=== BZOD Database Stats ===");
@@ -17,7 +19,12 @@ pub async fn run(
let p = config.data_dir.join(f);
if p.exists() {
let sz = std::fs::metadata(&p)?.len();
println!(" File: {} - Size: {} bytes ({:.2} MB)", f, sz, sz as f64 / 1_048_576.0);
println!(
" File: {} - Size: {} bytes ({:.2} MB)",
f,
sz,
sz as f64 / 1_048_576.0
);
}
}
@@ -31,7 +38,10 @@ pub async fn run(
let conn = db.content.lock().unwrap();
crate::db::content::get_url_counts(&conn)?
};
println!("Shortened URLs: {} total ({} active / {} dead)", urls_total, urls_active, urls_dead);
println!(
"Shortened URLs: {} total ({} active / {} dead)",
urls_total, urls_active, urls_dead
);
let pages_count = {
let conn = db.content.lock().unwrap();
+7 -5
View File
@@ -1,15 +1,17 @@
use std::path::PathBuf;
use tracing::info;
use reqwest::Client;
use std::time::Duration;
use crate::config::Config;
use crate::db::Db;
use reqwest::Client;
use std::path::PathBuf;
use std::time::Duration;
use tracing::info;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
+86 -27
View File
@@ -1,7 +1,7 @@
use std::path::PathBuf;
use serde::Deserialize;
use std::env;
use std::fs;
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Config {
@@ -18,6 +18,7 @@ pub struct Config {
pub backup_enabled: bool,
pub backup_interval_mins: u64,
pub backup_dir: PathBuf,
pub base_url: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -33,6 +34,7 @@ struct TomlConfig {
link_check_interval_mins: Option<u64>,
aggregation_interval_mins: Option<u64>,
backup: Option<TomlBackupConfig>,
base_url: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -50,7 +52,8 @@ impl Config {
let mut data_dir = PathBuf::from("./data");
let mut admin_username = "admin".to_string();
let mut bootstrap_password_sha256 = "".to_string();
let mut session_secret = "bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
let mut session_secret =
"bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
let mut cookie_secure = true;
let mut data_retention_days = None;
let mut link_check_interval_mins = 60u64;
@@ -58,6 +61,7 @@ impl Config {
let mut backup_enabled = false;
let mut backup_interval_mins = 1440u64; // Default: once per day
let mut backup_dir = PathBuf::from("./backups");
let mut base_url = None;
// 2. Load bzod.toml if it exists
let mut toml_path = "bzod.toml".to_string();
@@ -66,13 +70,27 @@ impl Config {
}
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host { host = h; }
if let Some(p) = toml_config.port { port = p; }
if let Some(d) = toml_config.data_dir { data_dir = PathBuf::from(d); }
if let Some(u) = toml_config.admin_username { admin_username = u; }
if let Some(s) = toml_config.bootstrap_password_sha256 { bootstrap_password_sha256 = s; }
if let Some(sec) = toml_config.session_secret { session_secret = sec; }
if let Some(c) = toml_config.cookie_secure { cookie_secure = c; }
if let Some(h) = toml_config.host {
host = h;
}
if let Some(p) = toml_config.port {
port = p;
}
if let Some(d) = toml_config.data_dir {
data_dir = PathBuf::from(d);
}
if let Some(u) = toml_config.admin_username {
admin_username = u;
}
if let Some(s) = toml_config.bootstrap_password_sha256 {
bootstrap_password_sha256 = s;
}
if let Some(sec) = toml_config.session_secret {
session_secret = sec;
}
if let Some(c) = toml_config.cookie_secure {
cookie_secure = c;
}
if let Some(ret) = toml_config.data_retention_days {
if ret.eq_ignore_ascii_case("unlimited") {
data_retention_days = None;
@@ -80,12 +98,25 @@ impl Config {
data_retention_days = Some(parsed);
}
}
if let Some(lc) = toml_config.link_check_interval_mins { link_check_interval_mins = lc; }
if let Some(ag) = toml_config.aggregation_interval_mins { aggregation_interval_mins = ag; }
if let Some(lc) = toml_config.link_check_interval_mins {
link_check_interval_mins = lc;
}
if let Some(ag) = toml_config.aggregation_interval_mins {
aggregation_interval_mins = ag;
}
if let Some(b) = toml_config.backup {
if let Some(be) = b.enabled { backup_enabled = be; }
if let Some(bi) = b.interval_mins { backup_interval_mins = bi; }
if let Some(bo) = b.out_dir { backup_dir = PathBuf::from(bo); }
if let Some(be) = b.enabled {
backup_enabled = be;
}
if let Some(bi) = b.interval_mins {
backup_interval_mins = bi;
}
if let Some(bo) = b.out_dir {
backup_dir = PathBuf::from(bo);
}
}
if let Some(bu) = toml_config.base_url {
base_url = Some(bu);
}
}
}
@@ -97,16 +128,30 @@ impl Config {
}
// 4. Load from Environment Variables (taking highest precedence)
if let Ok(h) = env::var("HOST") { host = h; }
if let Ok(h) = env::var("HOST") {
host = h;
}
if let Ok(p_str) = env::var("PORT") {
if let Ok(p) = p_str.parse::<u16>() { port = p; }
if let Ok(p) = p_str.parse::<u16>() {
port = p;
}
}
if let Ok(d_str) = env::var("DATA_DIR") {
data_dir = PathBuf::from(d_str);
}
if let Ok(u) = env::var("ADMIN_USERNAME") {
admin_username = u;
}
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") {
bootstrap_password_sha256 = s;
}
if let Ok(sec) = env::var("SESSION_SECRET") {
session_secret = sec;
}
if let Ok(d_str) = env::var("DATA_DIR") { data_dir = PathBuf::from(d_str); }
if let Ok(u) = env::var("ADMIN_USERNAME") { admin_username = u; }
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") { bootstrap_password_sha256 = s; }
if let Ok(sec) = env::var("SESSION_SECRET") { session_secret = sec; }
if let Ok(c_str) = env::var("COOKIE_SECURE") {
if let Ok(c) = c_str.parse::<bool>() { cookie_secure = c; }
if let Ok(c) = c_str.parse::<bool>() {
cookie_secure = c;
}
}
if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") {
if ret_str.eq_ignore_ascii_case("unlimited") {
@@ -116,18 +161,31 @@ impl Config {
}
}
if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") {
if let Ok(lc) = lc_str.parse::<u64>() { link_check_interval_mins = lc; }
if let Ok(lc) = lc_str.parse::<u64>() {
link_check_interval_mins = lc;
}
}
if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") {
if let Ok(ag) = ag_str.parse::<u64>() { aggregation_interval_mins = ag; }
if let Ok(ag) = ag_str.parse::<u64>() {
aggregation_interval_mins = ag;
}
}
if let Ok(be_str) = env::var("BACKUP_ENABLED") {
if let Ok(be) = be_str.parse::<bool>() { backup_enabled = be; }
if let Ok(be) = be_str.parse::<bool>() {
backup_enabled = be;
}
}
if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") {
if let Ok(bi) = bi_str.parse::<u64>() { backup_interval_mins = bi; }
if let Ok(bi) = bi_str.parse::<u64>() {
backup_interval_mins = bi;
}
}
if let Ok(bo_str) = env::var("BACKUP_DIR") {
backup_dir = PathBuf::from(bo_str);
}
if let Ok(bu) = env::var("BASE_URL") {
base_url = Some(bu);
}
if let Ok(bo_str) = env::var("BACKUP_DIR") { backup_dir = PathBuf::from(bo_str); }
Self {
host,
@@ -143,6 +201,7 @@ impl Config {
backup_enabled,
backup_interval_mins,
backup_dir,
base_url,
}
}
}
+39 -14
View File
@@ -1,9 +1,13 @@
use rusqlite::{Connection, params};
use uuid::Uuid;
use crate::models::{ApiKey, AuditLog, Session, User};
use chrono::Utc;
use crate::models::{User, Session, ApiKey, AuditLog};
use rusqlite::{params, Connection};
use uuid::Uuid;
pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> rusqlite::Result<User> {
pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<User> {
let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339();
@@ -21,7 +25,9 @@ pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> ru
}
pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;")?;
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;",
)?;
let mut rows = stmt.query(params![username])?;
if let Some(row) = rows.next()? {
@@ -37,7 +43,8 @@ pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Resu
}
pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
let mut stmt =
conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
@@ -64,10 +71,20 @@ pub fn create_session(
) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
// Bind `user_id` as integer when it appears to be numeric so that numeric
// user IDs inserted into `users.db` keep the integer affinity and avoid
// InvalidColumnType errors when read as i64 elsewhere.
if let Ok(id_i64) = user_id.parse::<i64>() {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, id_i64, expires_at_rfc3339, created_at],
)?;
} else {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
}
Ok(Session {
id: session_id.to_string(),
@@ -78,7 +95,8 @@ pub fn create_session(
}
pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result<Option<Session>> {
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let mut stmt =
conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let mut rows = stmt.query(params![session_id])?;
if let Some(row) = rows.next()? {
@@ -177,7 +195,10 @@ pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> {
pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute("UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;", params![now, id])?;
conn.execute(
"UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(())
}
@@ -211,10 +232,14 @@ pub fn write_audit_log(
})
}
pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<AuditLog>> {
pub fn list_audit_logs(
conn: &Connection,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<AuditLog>> {
let mut stmt = conn.prepare(
"SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;"
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;",
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
Ok(AuditLog {
+385 -48
View File
@@ -1,6 +1,6 @@
use rusqlite::{Connection, params};
use std::collections::HashMap;
use crate::models::VisitRecord;
use rusqlite::{params, Connection};
use std::collections::HashMap;
// Custom User-Agent parser to avoid bloated dependencies
pub fn parse_ua(ua: &str) -> (String, String, String) {
@@ -18,7 +18,8 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
"Android".to_string()
} else if ua_lower.contains("linux") {
"Linux".to_string()
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") {
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod")
{
"iOS".to_string()
} else {
"Other".to_string()
@@ -38,7 +39,11 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
"Other".to_string()
};
let device = if ua_lower.contains("mobile") || ua_lower.contains("android") || ua_lower.contains("iphone") || ua_lower.contains("ipod") {
let device = if ua_lower.contains("mobile")
|| ua_lower.contains("android")
|| ua_lower.contains("iphone")
|| ua_lower.contains("ipod")
{
"Mobile".to_string()
} else if ua_lower.contains("ipad") || ua_lower.contains("tablet") {
"Tablet".to_string()
@@ -62,7 +67,9 @@ pub fn clean_referrer(referer: &str) -> String {
}
// Fallback if not a valid URL
let cleaned = referer.trim_start_matches("https://").trim_start_matches("http://");
let cleaned = referer
.trim_start_matches("https://")
.trim_start_matches("http://");
let cleaned = cleaned.split('/').next().unwrap_or("Direct");
if cleaned.is_empty() {
"Direct".to_string()
@@ -75,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
)?;
for r in records {
@@ -90,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
r.referer,
r.accept_language,
r.country,
r.status_code
r.status_code,
r.owner_user_id
])?;
}
}
@@ -99,16 +107,25 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
}
pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'url';", [], |row| row.get(0))
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url';",
[],
|row| row.get(0),
)
}
pub fn get_total_page_views(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'page';", [], |row| row.get(0))
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'page';",
[],
|row| row.get(0),
)
}
// Get the date range of visits in the DB
pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result<Option<(String, String)>> {
let mut stmt = conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
let mut stmt =
conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
let min_date: Option<String> = row.get(0)?;
@@ -156,7 +173,6 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
return Ok(());
}
// 2. Compute metrics in-memory
// Key structure: (target_type, target_id, metric_type, metric_key) -> count
let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new();
@@ -165,7 +181,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
let country = if v.country.is_empty() {
"Unknown".to_string()
} else {
v.country.clone()
};
let targets = vec![
(v.target_type.clone(), v.target_id.clone()),
@@ -174,22 +194,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for (t_type, t_id) in targets {
// Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"clicks".to_string(),
"".to_string(),
))
.or_insert(0) += 1;
// Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"country".to_string(),
country.clone(),
))
.or_insert(0) += 1;
// Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"browser".to_string(),
browser.clone(),
))
.or_insert(0) += 1;
// OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
*aggregates
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
.or_insert(0) += 1;
// Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"device".to_string(),
device.clone(),
))
.or_insert(0) += 1;
// Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"referrer".to_string(),
referrer.clone(),
))
.or_insert(0) += 1;
}
}
@@ -197,7 +254,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let tx = conn.transaction()?;
{
// Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
tx.execute(
"DELETE FROM daily_summaries WHERE date = ?1;",
params![date],
)?;
let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
@@ -205,14 +265,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
)?;
for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
}
}
tx.commit()?;
@@ -227,7 +280,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
tx.execute(
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
params![year_month],
)?;
tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -244,7 +300,10 @@ fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqli
fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
tx.execute(
"DELETE FROM yearly_summaries WHERE year = ?1;",
params![year],
)?;
tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -262,7 +321,10 @@ fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Res
pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result<usize> {
let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days);
let limit_str = limit_date.to_rfc3339();
let count = conn.execute("DELETE FROM visits WHERE timestamp < ?1;", params![limit_str])?;
let count = conn.execute(
"DELETE FROM visits WHERE timestamp < ?1;",
params![limit_str],
)?;
Ok(count)
}
@@ -274,12 +336,14 @@ pub fn get_clicks_trend(
target_id: &str,
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).format("%Y-%m-%d").to_string();
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days))
.format("%Y-%m-%d")
.to_string();
let mut stmt = conn.prepare(
"SELECT date, SUM(metric_value) FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3
GROUP BY date ORDER BY date ASC;"
GROUP BY date ORDER BY date ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
@@ -305,7 +369,7 @@ pub fn get_clicks_trend_raw(
let mut stmt = conn.prepare(
"SELECT date(timestamp) as d, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY d ORDER BY d ASC;"
GROUP BY d ORDER BY d ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
@@ -331,7 +395,7 @@ pub fn get_hourly_trend_raw(
let mut stmt = conn.prepare(
"SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY h ORDER BY h ASC;"
GROUP BY h ORDER BY h ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
@@ -355,7 +419,7 @@ pub fn get_metric_rankings(
let mut stmt = conn.prepare(
"SELECT metric_key, SUM(metric_value) as val FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;"
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;",
)?;
let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| {
@@ -384,18 +448,20 @@ pub fn get_metric_rankings_raw(
let mut stmt = conn.prepare(
"SELECT country, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY country ORDER BY c DESC LIMIT ?3;"
GROUP BY country ORDER BY c DESC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for r in rows { res.push(r?); }
for r in rows {
res.push(r?);
}
}
"referrer" => {
let mut stmt = conn.prepare(
"SELECT referer, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY referer ORDER BY c DESC LIMIT ?3;"
GROUP BY referer ORDER BY c DESC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
let raw_ref: String = row.get(0)?;
@@ -416,7 +482,7 @@ pub fn get_metric_rankings_raw(
let mut stmt = conn.prepare(
"SELECT user_agent, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY user_agent;"
GROUP BY user_agent;",
)?;
let rows = stmt.query_map(params![target_type, target_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
@@ -443,30 +509,301 @@ pub fn get_metric_rankings_raw(
Ok(res)
}
/// Returns the raw visit counts for a specific target ID.
pub fn get_target_visit_count(
conn: &Connection,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2;",
params![target_type, target_id],
|row| row.get(0),
)
}
/// Returns the distinct IP count (Unique Visitors) for a specific target ID.
pub fn get_target_unique_visitors(
conn: &Connection,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(DISTINCT ip_address) FROM visits WHERE target_type = ?1 AND target_id = ?2;",
params![target_type, target_id],
|row| row.get(0),
)
}
/// Fetches the monthly clicks trend for a specific target ID, falling back to a raw visits query if monthly_summaries are empty.
pub fn get_monthly_clicks_trend(
conn: &Connection,
target_type: &str,
target_id: &str,
limit_months: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT year_month, SUM(metric_value) FROM monthly_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks'
GROUP BY year_month ORDER BY year_month ASC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
if res.is_empty() {
// Fallback to raw visits
let mut stmt = conn.prepare(
"SELECT strftime('%Y-%m', timestamp) as m, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY m ORDER BY m ASC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for r in rows {
res.push(r?);
}
}
Ok(res)
}
pub fn get_visits_schema_columns(
conn: &Connection,
) -> rusqlite::Result<std::collections::HashSet<String>> {
let mut columns = std::collections::HashSet::new();
let mut stmt = conn.prepare("PRAGMA table_info(visits);")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let name: String = row.get("name")?;
columns.insert(name);
}
Ok(columns)
}
pub fn get_target_visits_paginated(
conn: &Connection,
target_type: &str,
target_id: &str,
limit: i64,
offset: i64,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
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 LIMIT ?");
sql.push_str(&(params.len() + 1).to_string());
params.push(Box::new(limit));
sql.push_str(" OFFSET ?");
sql.push_str(&(params.len() + 1).to_string());
params.push(Box::new(offset));
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| {
Ok(VisitRecord {
id: row.get("id")?,
target_type: row.get("target_type")?,
target_id: row.get("target_id")?,
timestamp: row.get("timestamp")?,
ip_address: row.get("ip_address")?,
user_agent: row.get("user_agent")?,
referer: row.get("referer")?,
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_user_id: row.get("owner_user_id")?,
})
})?;
let mut visits = Vec::new();
for r in rows {
visits.push(r?);
}
Ok(visits)
}
pub fn get_target_visits_all_in_memory(
conn: &Connection,
target_type: &str,
target_id: &str,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
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 = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| {
Ok(VisitRecord {
id: row.get("id")?,
target_type: row.get("target_type")?,
target_id: row.get("target_id")?,
timestamp: row.get("timestamp")?,
ip_address: row.get("ip_address")?,
user_agent: row.get("user_agent")?,
referer: row.get("referer")?,
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_user_id: row.get("owner_user_id")?,
})
})?;
let mut visits = Vec::new();
for r in rows {
visits.push(r?);
}
Ok(visits)
}
pub fn get_target_visit_total_filtered(
conn: &Connection,
target_type: &str,
target_id: &str,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<i64> {
if date_from.is_none() && date_to.is_none() {
return get_target_visit_count(conn, target_type, target_id);
}
let mut sql =
"SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
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")
)));
}
}
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
conn.query_row(&sql, rusqlite::params_from_iter(param_refs), |row| {
row.get(0)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ua_browsers() {
let firefox_linux = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
let firefox_linux =
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
let chrome_win = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
let safari_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15";
let android_phone = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36";
assert_eq!(parse_ua(firefox_linux), ("Firefox".to_string(), "Linux".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(chrome_win), ("Chrome".to_string(), "Windows".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(safari_mac), ("Safari".to_string(), "macOS".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(android_phone), ("Chrome".to_string(), "Android".to_string(), "Mobile".to_string()));
assert_eq!(
parse_ua(firefox_linux),
(
"Firefox".to_string(),
"Linux".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(chrome_win),
(
"Chrome".to_string(),
"Windows".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(safari_mac),
(
"Safari".to_string(),
"macOS".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(android_phone),
(
"Chrome".to_string(),
"Android".to_string(),
"Mobile".to_string()
)
);
}
#[test]
fn test_clean_referrer() {
assert_eq!(clean_referrer("direct"), "Direct");
assert_eq!(clean_referrer(""), "Direct");
assert_eq!(clean_referrer("https://github.com/rust-lang/rust"), "github.com");
assert_eq!(clean_referrer("http://www.google.com/search?q=rust"), "google.com");
assert_eq!(
clean_referrer("https://github.com/rust-lang/rust"),
"github.com"
);
assert_eq!(
clean_referrer("http://www.google.com/search?q=rust"),
"google.com"
);
assert_eq!(clean_referrer("reddit.com/r/rust"), "reddit.com");
}
}
+74
View File
@@ -0,0 +1,74 @@
use crate::models::AuditEvent;
use chrono::Utc;
use rusqlite::{params, Connection};
use uuid::Uuid;
/// Write an audit event to the system.db audit_events table.
pub fn write_audit_event(
conn: &Connection,
actor: &str,
action: &str,
object_type: &str,
object_id: &str,
metadata: Option<&str>,
) -> rusqlite::Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO audit_events (id, actor, action, object_type, object_id, timestamp, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![id, actor, action, object_type, object_id, now, metadata],
)?;
Ok(())
}
/// List audit events with optional filtering by actor or action.
pub fn list_audit_events(
conn: &Connection,
limit: i64,
offset: i64,
actor_filter: Option<&str>,
action_filter: Option<&str>,
) -> rusqlite::Result<Vec<AuditEvent>> {
let mut events = Vec::new();
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match (actor_filter, action_filter) {
(Some(actor), Some(action)) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 AND action = ?2 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4;".to_string(),
vec![Box::new(actor.to_string()), Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
),
(Some(actor), None) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
vec![Box::new(actor.to_string()), Box::new(limit), Box::new(offset)],
),
(None, Some(action)) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE action = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
vec![Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
),
(None, None) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;".to_string(),
vec![Box::new(limit), Box::new(offset)],
),
};
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_refs.as_slice(), |row| {
Ok(AuditEvent {
id: row.get(0)?,
actor: row.get(1)?,
action: row.get(2)?,
object_type: row.get(3)?,
object_id: row.get(4)?,
timestamp: row.get(5)?,
metadata: row.get(6)?,
})
})?;
for event in rows {
events.push(event?);
}
Ok(events)
}
+252 -85
View File
@@ -1,7 +1,7 @@
use rusqlite::{Connection, params};
use uuid::Uuid;
use crate::models::Url;
use chrono::Utc;
use crate::models::{Url, LandingPage};
use rusqlite::{params, Connection};
use uuid::Uuid;
// Helper: Associate tags with a URL
fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> {
@@ -47,6 +47,44 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
Ok(tags)
}
/// Get the total count of URLs associated with a specific tag name.
pub fn get_url_count_by_tag(conn: &Connection, tag: &str) -> rusqlite::Result<i64> {
let tag_name = tag.trim().to_lowercase();
conn.query_row(
"SELECT COUNT(*) FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1;",
params![tag_name],
|row| row.get(0),
)
}
/// The full column list used in all URL SELECT queries.
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
/// Build a Url struct from a row containing URL_COLUMNS in order.
fn url_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Url> {
Ok(Url {
id: row.get(0)?,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
expires_at: row.get(8)?,
expired: row.get::<_, i32>(9).unwrap_or(0) != 0,
password_hash: row.get(10)?,
last_status: row.get(11)?,
last_latency_ms: row.get(12)?,
max_access_count: row.get(13)?,
access_count: row.get::<_, i64>(14).unwrap_or(0),
tags: Vec::new(), // filled after query
})
}
pub fn create_url(
conn: &Connection,
code: &str,
@@ -77,54 +115,84 @@ pub fn create_url(
created_at: now.clone(),
updated_at: now,
tags: tags.to_vec(),
expires_at: None,
expired: false,
password_hash: None,
last_status: None,
last_latency_ms: None,
max_access_count: None,
access_count: 0,
})
}
/// Create a URL with all extended options.
#[allow(clippy::too_many_arguments)]
pub fn create_url_extended(
conn: &Connection,
code: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
tags: &[String],
expires_at: Option<&str>,
password_hash: Option<&str>,
max_access_count: Option<i64>,
) -> rusqlite::Result<Url> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let status = "healthy".to_string();
conn.execute(
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, password_hash, max_access_count)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);",
params![id, code, destination, title, description, status, now, now, expires_at, password_hash, max_access_count],
)?;
associate_tags(conn, &id, tags)?;
Ok(Url {
id,
code: code.to_string(),
destination: destination.to_string(),
title: title.map(|s| s.to_string()),
description: description.map(|s| s.to_string()),
status,
created_at: now.clone(),
updated_at: now,
tags: tags.to_vec(),
expires_at: expires_at.map(|s| s.to_string()),
expired: false,
password_hash: password_hash.map(|s| s.to_string()),
last_status: None,
last_latency_ms: None,
max_access_count,
access_count: 0,
})
}
pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE id = ?1;"
)?;
let sql = format!("SELECT {} FROM urls WHERE id = ?1;", URL_COLUMNS);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?;
let tags = get_tags_for_url(conn, &url_id)?;
Ok(Some(Url {
id: url_id,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
tags,
}))
let mut url = url_from_row(row)?;
url.tags = get_tags_for_url(conn, &url.id)?;
Ok(Some(url))
} else {
Ok(None)
}
}
pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE code = ?1;"
)?;
let sql = format!("SELECT {} FROM urls WHERE code = ?1;", URL_COLUMNS);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(params![code])?;
if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?;
let tags = get_tags_for_url(conn, &url_id)?;
Ok(Some(Url {
id: url_id,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
tags,
}))
let mut url = url_from_row(row)?;
url.tags = get_tags_for_url(conn, &url.id)?;
Ok(Some(url))
} else {
Ok(None)
}
@@ -170,58 +238,49 @@ pub fn list_urls(
if let Some(tag) = tag_filter {
let tag_name = tag.trim().to_lowercase();
let mut stmt = conn.prepare(
"SELECT u.id, u.code, u.destination, u.title, u.description, u.status, u.created_at, u.updated_at
FROM urls u
let sql = format!(
"SELECT u.{} FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;"
)?;
let rows = stmt.query_map(params![tag_name, limit, offset], |row| {
let url_id: String = row.get(0)?;
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
})?;
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;",
URL_COLUMNS
.replace("id,", "u.id,")
.replace(", code", ", u.code")
.replace(", destination", ", u.destination")
.replace(", title", ", u.title")
.replace(", description", ", u.description")
.replace(", status", ", u.status")
.replace(", created_at", ", u.created_at")
.replace(", updated_at", ", u.updated_at")
.replace(", expires_at", ", u.expires_at")
.replace(", expired", ", u.expired")
.replace(", password_hash", ", u.password_hash")
.replace(", last_status", ", u.last_status")
.replace(", last_latency_ms", ", u.last_latency_ms")
.replace(", max_access_count", ", u.max_access_count")
.replace(", access_count", ", u.access_count")
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![tag_name, limit, offset], url_from_row)?;
for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
let tags = get_tags_for_url(conn, &url_id)?;
urls.push(Url {
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
let mut url = r?;
url.tags = get_tags_for_url(conn, &url.id)?;
urls.push(url);
}
} else {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at
FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
let url_id: String = row.get(0)?;
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
})?;
let sql = format!(
"SELECT {} FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
URL_COLUMNS
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![limit, offset], url_from_row)?;
for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
let tags = get_tags_for_url(conn, &url_id)?;
urls.push(Url {
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
let mut url = r?;
url.tags = get_tags_for_url(conn, &url.id)?;
urls.push(url);
}
}
@@ -229,7 +288,7 @@ pub fn list_urls(
}
pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT id, destination FROM urls;")?;
let mut stmt = conn.prepare("SELECT id, destination FROM urls WHERE expired = 0;")?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut res = Vec::new();
for r in rows {
@@ -247,13 +306,111 @@ pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite:
Ok(())
}
/// Update URL health with extended status and latency information.
pub fn update_url_health_extended(
conn: &Connection,
id: &str,
status: &str,
last_status: &str,
latency_ms: Option<i64>,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE urls SET status = ?1, last_status = ?2, last_latency_ms = ?3, updated_at = ?4 WHERE id = ?5;",
params![status, last_status, latency_ms, now, id],
)?;
Ok(())
}
pub fn get_url_counts(conn: &Connection) -> rusqlite::Result<(i64, i64, i64)> {
let total: i64 = conn.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))?;
let active: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect');", [], |row| row.get(0))?;
let dead: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status = 'dead';", [], |row| row.get(0))?;
let active: i64 = conn.query_row(
"SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect') AND expired = 0;",
[],
|row| row.get(0),
)?;
let dead: i64 = conn.query_row(
"SELECT COUNT(*) FROM urls WHERE status = 'dead' OR expired = 1;",
[],
|row| row.get(0),
)?;
Ok((total, active, dead))
}
/// Mark all URLs with expires_at < now as expired.
pub fn expire_urls(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET expired = 1, updated_at = ?1 WHERE expires_at IS NOT NULL AND expires_at < ?1 AND expired = 0;",
params![now],
)?;
Ok(count)
}
/// Set a password hash on a URL.
pub fn set_url_password(
conn: &Connection,
id: &str,
password_hash: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET password_hash = ?1, updated_at = ?2 WHERE id = ?3;",
params![password_hash, now, id],
)?;
Ok(count > 0)
}
/// Remove the password from a URL.
pub fn remove_url_password(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET password_hash = NULL, updated_at = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(count > 0)
}
/// Atomically increment the access count and return the new value.
pub fn increment_access_count(conn: &Connection, id: &str) -> rusqlite::Result<i64> {
conn.execute(
"UPDATE urls SET access_count = access_count + 1 WHERE id = ?1;",
params![id],
)?;
conn.query_row(
"SELECT access_count FROM urls WHERE id = ?1;",
params![id],
|row| row.get(0),
)
}
/// Set expiry on a URL.
pub fn set_url_expiry(conn: &Connection, id: &str, expires_at: &str) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET expires_at = ?1, updated_at = ?2 WHERE id = ?3;",
params![expires_at, now, id],
)?;
Ok(count > 0)
}
/// Get health status summary across all URLs.
pub fn get_health_summary(conn: &Connection) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT COALESCE(last_status, status) AS health, COUNT(*) FROM urls GROUP BY health ORDER BY COUNT(*) DESC;"
)?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
Ok(res)
}
// --- Landing Page Operations (unchanged) ---
use crate::models::LandingPage;
pub fn create_landing_page(
conn: &Connection,
code: &str,
@@ -283,7 +440,10 @@ pub fn create_landing_page(
})
}
pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<LandingPage>> {
pub fn get_landing_page_by_id(
conn: &Connection,
id: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;"
)?;
@@ -305,7 +465,10 @@ pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<O
}
}
pub fn get_landing_page_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<LandingPage>> {
pub fn get_landing_page_by_code(
conn: &Connection,
code: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;"
)?;
@@ -354,10 +517,14 @@ pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result<bool
Ok(count > 0)
}
pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<LandingPage>> {
pub fn list_landing_pages(
conn: &Connection,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
Ok(LandingPage {
+281 -4
View File
@@ -27,7 +27,12 @@ pub fn run_migrations(
if current_version < target_version {
for m in migrations.iter().filter(|m| m.version > current_version) {
info!(database = db_name, version = m.version, name = m.name, "Applying migration");
info!(
database = db_name,
version = m.version,
name = m.name,
"Applying migration"
);
let tx = conn.transaction()?;
tx.execute_batch(m.sql)?;
@@ -35,7 +40,12 @@ pub fn run_migrations(
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
info!(database = db_name, version = m.version, name = m.name, "Migration completed");
info!(
database = db_name,
version = m.version,
name = m.name,
"Migration completed"
);
// Write audit record to system.db.migrations
if let Some(sys_db_mutex) = system_db_opt {
@@ -58,7 +68,11 @@ pub fn run_migrations(
}
}
} else {
info!(database = db_name, version = current_version, "Database up to date");
info!(
database = db_name,
version = current_version,
"Database up to date"
);
}
Ok(())
@@ -77,7 +91,10 @@ pub fn print_migration_plan(
println!(" Current version: {current_version}");
println!(" Target version: {target_version}");
let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect();
let pending: Vec<&Migration> = migrations
.iter()
.filter(|m| m.version > current_version)
.collect();
if pending.is_empty() {
println!(" Status: up to date");
@@ -141,6 +158,24 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[
);
"#,
},
Migration {
version: 2,
name: "remove_api_keys_fk",
sql: r#"
CREATE TABLE api_keys_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
INSERT INTO api_keys_new (id, user_id, key_hash, name, created_at, last_used_at)
SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys;
DROP TABLE api_keys;
ALTER TABLE api_keys_new RENAME TO api_keys;
"#,
},
];
pub const CONTENT_MIGRATIONS: &[Migration] = &[
@@ -187,6 +222,49 @@ pub const CONTENT_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code);
"#,
},
Migration {
version: 2,
name: "features_expansion",
sql: r#"
-- Expiring Links
ALTER TABLE urls ADD COLUMN expires_at TEXT NULL;
ALTER TABLE urls ADD COLUMN expired INTEGER NOT NULL DEFAULT 0;
-- Password Protected Links
ALTER TABLE urls ADD COLUMN password_hash TEXT NULL;
-- Link Health Dashboard (extended columns)
ALTER TABLE urls ADD COLUMN last_status TEXT;
ALTER TABLE urls ADD COLUMN last_latency_ms INTEGER;
-- One-Time Links
ALTER TABLE urls ADD COLUMN max_access_count INTEGER NULL;
ALTER TABLE urls ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0;
-- Smart Landing Pages / Link Preview
CREATE TABLE IF NOT EXISTS link_preview (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL UNIQUE,
title TEXT,
description TEXT,
logo_url TEXT,
button_text TEXT DEFAULT 'Continue',
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
);
-- QR Code style metadata
CREATE TABLE IF NOT EXISTS qr_codes (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL,
style TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL,
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_urls_expired ON urls(expired);
CREATE INDEX IF NOT EXISTS idx_urls_expires_at ON urls(expires_at);
"#,
},
];
pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
@@ -241,6 +319,27 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id);
"#,
},
Migration {
version: 2,
name: "qr_access_log",
sql: r#"
CREATE TABLE IF NOT EXISTS qr_access_log (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_qr_access_url ON qr_access_log(url_id);
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
"#,
},
Migration {
version: 3,
name: "add_owner_user_id",
sql: "ALTER TABLE visits ADD COLUMN owner_user_id INTEGER;",
},
];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
@@ -291,4 +390,182 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
);
"#,
},
Migration {
version: 2,
name: "audit_events",
sql: r#"
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL,
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#,
},
Migration {
version: 3,
name: "global_slugs_and_moderation",
sql: r#"
CREATE TABLE IF NOT EXISTS global_slugs (
slug TEXT PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_owner ON global_slugs(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_global_slugs_status ON global_slugs(status);
CREATE INDEX IF NOT EXISTS idx_global_slugs_target ON global_slugs(target_type, target_id);
CREATE TABLE IF NOT EXISTS moderation_events (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
admin_username TEXT NOT NULL,
target_user_id INTEGER NOT NULL,
target_username TEXT,
resource_type TEXT NOT NULL,
resource_identifier TEXT NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS slug_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
old_owner_user_id INTEGER,
new_owner_user_id INTEGER,
action TEXT NOT NULL,
timestamp TEXT NOT NULL,
admin_username TEXT
);
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Seed defaults
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
INSERT OR IGNORE INTO settings (key, value) VALUES ('soft_delete_retention_days', '30');
INSERT OR IGNORE INTO settings (key, value) VALUES ('quota_reconcile_interval_hours', '24');
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_registration', 'false');
INSERT OR IGNORE INTO settings (key, value) VALUES ('maintenance_mode', 'false');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('admin', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('login', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('logout', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('dashboard', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('api', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('docs', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('assets', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('static', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('favicon.ico', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('robots.txt', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('health', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('metrics', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('install', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('setup', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('support', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('help', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('security', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('abuse', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('billing', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('status', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('legacy_admin', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('administrator', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('system', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('root', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('www', 'System reserved');
"#,
},
];
pub const USERS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT,
account_type TEXT DEFAULT 'standard',
organization_id INTEGER NULL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS quotas (
user_id INTEGER PRIMARY KEY,
max_urls INTEGER DEFAULT 100,
max_landings INTEGER DEFAULT 10,
max_api_tokens INTEGER DEFAULT 5,
max_storage_mb INTEGER DEFAULT 100,
current_urls INTEGER DEFAULT 0,
current_landings INTEGER DEFAULT 0,
current_api_tokens INTEGER DEFAULT 0,
current_storage_mb INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS username_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
old_username TEXT NOT NULL,
new_username TEXT NOT NULL,
changed_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
"#,
},
Migration {
version: 2,
name: "repair_admin_account_type",
sql: r#"
UPDATE users
SET account_type = 'admin'
WHERE username = 'admin' AND account_type = 'standard';
"#,
},
];
+408 -45
View File
@@ -1,15 +1,22 @@
use crate::config::Config;
use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
USERS_MIGRATIONS,
};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
use std::fs;
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use crate::config::Config;
use crate::db::migrations::{run_migrations, ADMIN_MIGRATIONS, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS, SYSTEM_MIGRATIONS};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
pub mod migrations;
pub mod sqlite;
pub mod admin;
pub mod content;
pub mod analytics;
pub mod audit_events;
pub mod content;
pub mod migrations;
pub mod preview;
pub mod qr;
pub mod sqlite;
pub mod users;
#[derive(Clone)]
pub struct Db {
@@ -17,85 +24,441 @@ pub struct Db {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
}
impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
use chrono::Utc;
use tracing::info;
// Ensure data directory exists
if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?;
}
let admin_path = config.data_dir.join("admin.db");
let content_path = config.data_dir.join("content.db");
let analytics_path = config.data_dir.join("analytics.db");
let system_path = config.data_dir.join("system.db");
let admin_dir = config.data_dir.join("admin");
let users_dir = config.data_dir.join("users");
fs::create_dir_all(&admin_dir)?;
fs::create_dir_all(&users_dir)?;
use tracing::info;
// Automated Legacy Migration: check if legacy files are at the root
let legacy_admin_db = config.data_dir.join("admin.db");
let legacy_content_db = config.data_dir.join("content.db");
let legacy_analytics_db = config.data_dir.join("analytics.db");
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
if legacy_admin_db.exists() {
info!("Legacy admin.db found at root. Moving administrative databases to admin/ subfolder...");
let files = vec![
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
];
for f in files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
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");
info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?;
info!("Opening content.db");
let mut content_conn = Connection::open(content_path)?;
info!("Opening analytics.db");
let mut analytics_conn = Connection::open(analytics_path)?;
info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?;
info!("Opening users.db");
let mut users_conn = Connection::open(users_db_path)?;
// Enable WAL mode for better concurrency and write performance
info!(database = "admin", "Enabling WAL mode on admin.db");
enable_wal(&admin_conn, "admin")?;
info!(database = "content", "Enabling WAL mode on content.db");
enable_wal(&content_conn, "content")?;
info!(database = "analytics", "Enabling WAL mode on analytics.db");
enable_wal(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling WAL mode on system.db");
enable_wal(&system_conn, "system")?;
enable_wal(&users_conn, "users")?;
// Enable foreign key support
info!(database = "admin", "Enabling foreign key enforcement on admin.db");
enable_foreign_keys(&admin_conn, "admin")?;
info!(database = "content", "Enabling foreign key enforcement on content.db");
enable_foreign_keys(&content_conn, "content")?;
info!(database = "analytics", "Enabling foreign key enforcement on analytics.db");
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling foreign key enforcement on system.db");
enable_foreign_keys(&system_conn, "system")?;
enable_foreign_keys(&users_conn, "users")?;
// 1. Run migrations for system.db first, as it receives secondary audit records
// Run migrations for system.db first
info!("Running system migrations");
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
let system_arc = Arc::new(Mutex::new(system_conn));
// 2. Run migrations for other databases with system.db logging
info!("Running admin migrations");
run_migrations(&mut admin_conn, "admin", ADMIN_MIGRATIONS, Some(&system_arc))?;
info!("Running content migrations");
run_migrations(&mut content_conn, "content", CONTENT_MIGRATIONS, Some(&system_arc))?;
info!("Running analytics migrations");
run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?;
// Pre-migration detection of admin account repair
let repair_needed = {
let stmt = users_conn.prepare(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'standard');"
);
match stmt {
Ok(mut s) => s
.query_row([], |row| row.get::<_, bool>(0))
.unwrap_or(false),
Err(_) => false,
}
};
Ok(Self {
// Run migrations for admin.db and users.db
info!("Running admin migrations");
run_migrations(
&mut admin_conn,
"admin",
ADMIN_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running users migrations");
run_migrations(
&mut users_conn,
"users",
USERS_MIGRATIONS,
Some(&system_arc),
)?;
// Post-migration: audit log if repaired
if repair_needed {
let admin_is_now_admin: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'admin');",
[],
|row| row.get(0),
)
.unwrap_or(false);
if admin_is_now_admin {
let system_conn = system_arc.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"admin",
"migration_repair",
"users",
"admin",
Some("Repaired standard account type to admin"),
);
}
}
// Clean up expired sessions from users.db on startup
let now = Utc::now().to_rfc3339();
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
// Ensure legacy_admin (user ID 1) exists in users.db
let legacy_admin_id = 1i64;
let legacy_admin_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[legacy_admin_id],
|row| row.get(0),
)
.unwrap_or(false);
if !legacy_admin_exists {
// Get copied administrator password hash
let admin_password_hash: String = admin_conn
.query_row(
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| {
// If admin_db is empty, hash a default password
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
});
let now = Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
legacy_admin_id,
"legacy_admin",
admin_password_hash,
"disabled",
now,
"system"
],
)?;
// Seed quotas
users_conn.execute(
"INSERT INTO quotas (user_id) VALUES (?1);",
[legacy_admin_id],
)?;
}
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
fs::create_dir_all(&legacy_user_dir)?;
if legacy_content_db.exists() || legacy_analytics_db.exists() {
info!("Legacy content/analytics databases found at root. Moving to user ID 1 directory...");
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
for f in content_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
for f in analytics_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
let content_path = legacy_user_dir.join("content.db");
let analytics_path = legacy_user_dir.join("analytics.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
// Run migrations for content.db and analytics.db
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&system_arc),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&system_arc),
)?;
// If we just migrated legacy content, populate the global_slugs table in system.db
if legacy_migration_needed {
info!("Populating global slug index with legacy content...");
let mut sys_lock = system_arc.lock().unwrap();
let tx = sys_lock.transaction()?;
// Extract urls from content.db and insert into global_slugs
{
let mut stmt =
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 global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = tx.execute(
"INSERT OR IGNORE 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, legacy_admin_id, "url", target_id, created_at, now, global_status],
);
}
}
// Extract landing pages from content.db and insert into global_slugs
{
let mut stmt = 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 _ = tx.execute(
"INSERT OR IGNORE 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, legacy_admin_id, "page", target_id, created_at, now, status],
);
}
}
tx.commit()?;
info!("Global slug index populated successfully.");
}
let db = Self {
admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc,
})
users: Arc::new(Mutex::new(users_conn)),
data_dir: config.data_dir.clone(),
};
let _ = db.reconcile_global_slugs(config);
Ok(db)
}
pub fn compact(&self) -> Result<(), rusqlite::Error> {
let admin = self.admin.lock().unwrap();
admin.execute("VACUUM;", [])?;
let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap();
content.execute("VACUUM;", [])?;
let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap();
analytics.execute("VACUUM;", [])?;
let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap();
system.execute("VACUUM;", [])?;
let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []);
Ok(())
}
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user_dir = self.data_dir.join("users").join(user_id.to_string());
fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_wal(&profile_conn, "profile")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
enable_foreign_keys(&profile_conn, "profile")?;
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&self.system),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&self.system),
)?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(())
}
pub fn reconcile_global_slugs(
&self,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc;
let system_conn = self.system.lock().unwrap();
let users_conn = self.users.lock().unwrap();
// Get all user IDs
let mut stmt = users_conn.prepare("SELECT id FROM users;")?;
let mut rows = stmt.query([])?;
let mut user_ids = vec![1i64]; // Start with legacy admin
while let Some(row) = rows.next()? {
user_ids.push(row.get(0)?);
}
drop(rows);
drop(stmt);
for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let content_path = if user_id == 1 {
config.data_dir.join("content.db") // legacy admin content db path
} else {
user_dir.join("content.db")
};
if content_path.exists() {
let content_conn = Connection::open(&content_path)?;
// Sync URLs
let mut stmt =
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 target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE 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![code, user_id, "url", target_id, created_at, now, global_status],
);
}
// Sync Landing Pages
let mut stmt = 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 target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let global_status = if state == "published" {
"active"
} else {
"disabled"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE 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![code, user_id, "page", target_id, created_at, now, global_status],
);
}
}
}
Ok(())
}
+62
View File
@@ -0,0 +1,62 @@
use crate::models::LinkPreview;
use rusqlite::{params, Connection};
use uuid::Uuid;
/// Insert or update a link preview for a URL.
pub fn upsert_preview(
conn: &Connection,
url_id: &str,
title: Option<&str>,
description: Option<&str>,
logo_url: Option<&str>,
button_text: Option<&str>,
) -> rusqlite::Result<LinkPreview> {
let id = Uuid::new_v4().to_string();
let btn = button_text.unwrap_or("Continue");
conn.execute(
"INSERT INTO link_preview (id, url_id, title, description, logo_url, button_text)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(url_id) DO UPDATE SET
title = excluded.title,
description = excluded.description,
logo_url = excluded.logo_url,
button_text = excluded.button_text;",
params![id, url_id, title, description, logo_url, btn],
)?;
// Return the current state (may have been an update with a different id)
get_preview(conn, url_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)
}
/// Get the link preview for a URL.
pub fn get_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<Option<LinkPreview>> {
let mut stmt = conn.prepare(
"SELECT id, url_id, title, description, logo_url, button_text FROM link_preview WHERE url_id = ?1;"
)?;
let mut rows = stmt.query(params![url_id])?;
if let Some(row) = rows.next()? {
Ok(Some(LinkPreview {
id: row.get(0)?,
url_id: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
logo_url: row.get(4)?,
button_text: row
.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "Continue".to_string()),
}))
} else {
Ok(None)
}
}
/// Delete the link preview for a URL.
pub fn delete_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<bool> {
let count = conn.execute(
"DELETE FROM link_preview WHERE url_id = ?1;",
params![url_id],
)?;
Ok(count > 0)
}
+90
View File
@@ -0,0 +1,90 @@
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
use uuid::Uuid;
/// Log a QR code access event to the analytics database.
pub fn log_qr_access(
conn: &Connection,
url_id: &str,
ip: Option<&str>,
user_agent: Option<&str>,
) -> rusqlite::Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO qr_access_log (id, url_id, timestamp, ip, user_agent)
VALUES (?1, ?2, ?3, ?4, ?5);",
params![id, url_id, now, ip, user_agent],
)?;
Ok(())
}
/// Get QR scan count for a URL.
pub fn get_qr_scan_count(conn: &Connection, url_id: &str) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM qr_access_log WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
}
/// Get QR scan count for a URL by its code (joins with content.db — must be called on analytics db after lookup).
pub fn get_qr_stats_for_url(
conn: &Connection,
url_id: &str,
) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare(
"SELECT timestamp, ip FROM qr_access_log WHERE url_id = ?1 ORDER BY timestamp DESC LIMIT 100;"
)?;
let rows = stmt.query_map(params![url_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?.unwrap_or_default(),
))
})?;
let mut results = Vec::new();
for r in rows {
results.push(r?);
}
Ok(results)
}
/// Create or update a QR code style registration in the content database.
pub fn upsert_qr_code(conn: &Connection, url_id: &str, style: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
// Check if entry already exists
let existing_id: Option<String> = conn
.query_row(
"SELECT id FROM qr_codes WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
.optional()?;
if let Some(id) = existing_id {
conn.execute(
"UPDATE qr_codes SET style = ?1 WHERE id = ?2;",
params![style, id],
)?;
} else {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO qr_codes (id, url_id, style, created_at) VALUES (?1, ?2, ?3, ?4);",
params![id, url_id, style, now],
)?;
}
Ok(())
}
/// Get the style configured for a QR code.
pub fn get_qr_code_style(conn: &Connection, url_id: &str) -> rusqlite::Result<String> {
let style: Option<String> = conn
.query_row(
"SELECT style FROM qr_codes WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
.optional()?;
Ok(style.unwrap_or_else(|| "default".to_string()))
}
+9 -5
View File
@@ -14,8 +14,9 @@ use tracing::info;
/// Uses `query_row` with `PRAGMA journal_mode=WAL` which both sets and returns
/// the actual mode. Returns an error if the database does not confirm WAL mode.
pub fn enable_wal(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
let actual_mode: String =
conn.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get::<_, String>(0))?;
let actual_mode: String = conn.query_row("PRAGMA journal_mode=WAL;", [], |row| {
row.get::<_, String>(0)
})?;
info!(database = db_name, mode = %actual_mode, "WAL mode configured");
@@ -36,7 +37,11 @@ pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusql
let enabled: bool =
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
info!(database = db_name, foreign_keys = enabled, "Foreign key enforcement configured");
info!(
database = db_name,
foreign_keys = enabled,
"Foreign key enforcement configured"
);
if !enabled {
return Err(rusqlite::Error::QueryReturnedNoRows);
@@ -183,8 +188,7 @@ mod tests {
#[test]
fn test_collect_health_report() {
let conn = memory_conn();
let report =
collect_health_report(&conn, "test").expect("Failed to collect health report");
let report = collect_health_report(&conn, "test").expect("Failed to collect health report");
assert_eq!(report.database, "test");
assert!(report.integrity_ok);
}
+545
View File
@@ -0,0 +1,545 @@
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
// --- User Operations ---
pub fn is_reserved_username(username: &str) -> bool {
let u = username.trim().to_lowercase();
u == "admin" || u == "legacy_admin" || u == "administrator" || u == "system" || u == "root"
}
pub fn create_admin_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<TenantUser> {
let created_at = Utc::now().to_rfc3339();
let status = "active";
let account_type = "admin";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
params![username, password_hash, status, created_at, account_type],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: None,
})
}
pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
account_type: &str,
metadata: Option<&str>,
) -> rusqlite::Result<TenantUser> {
if is_reserved_username(username) {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Username is reserved".to_string()),
));
}
let created_at = Utc::now().to_rfc3339();
let status = "active";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
params![
username,
password_hash,
status,
created_at,
account_type,
metadata
],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: metadata.map(|s| s.to_string()),
})
}
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1;",
params![id],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
pub fn get_user_by_username(
conn: &Connection,
username: &str,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
params![username],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
pub fn delete_user(conn: &Connection, id: i64) -> rusqlite::Result<()> {
conn.execute("DELETE FROM users WHERE id = ?1;", params![id])?;
Ok(())
}
pub fn update_user_status(conn: &Connection, id: i64, status: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET status = ?1 WHERE id = ?2;",
params![status, id],
)?;
Ok(())
}
pub fn update_user_account_type(
conn: &Connection,
id: i64,
account_type: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET account_type = ?1 WHERE id = ?2;",
params![account_type, id],
)?;
Ok(())
}
pub fn reset_user_password(
conn: &Connection,
id: i64,
new_password_hash: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET password_hash = ?1 WHERE id = ?2;",
params![new_password_hash, id],
)?;
Ok(())
}
pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE users SET last_login = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(())
}
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users ORDER BY username ASC;",
)?;
let rows = stmt.query_map([], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})?;
let mut users = Vec::new();
for u in rows {
users.push(u?);
}
Ok(users)
}
pub fn log_username_change(
conn: &Connection,
user_id: i64,
old_username: &str,
new_username: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO username_history (user_id, old_username, new_username, changed_at) VALUES (?1, ?2, ?3, ?4);",
params![user_id, old_username, new_username, now],
)?;
conn.execute(
"UPDATE users SET username = ?1 WHERE id = ?2;",
params![new_username, user_id],
)?;
Ok(())
}
// --- Session Operations ---
pub fn create_user_session(
conn: &Connection,
session_id: &str,
user_id: i64,
expires_at_rfc3339: &str,
) -> rusqlite::Result<UserSession> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
Ok(UserSession {
id: session_id.to_string(),
user_id,
expires_at: expires_at_rfc3339.to_string(),
created_at,
})
}
pub fn get_user_session(
conn: &Connection,
session_id: &str,
) -> rusqlite::Result<Option<UserSession>> {
conn.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
params![session_id],
|row| {
Ok(UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
}
pub fn delete_user_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?;
Ok(())
}
pub fn cleanup_expired_user_sessions(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?;
Ok(count)
}
// --- Quota Operations ---
pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<UserQuotas>> {
conn.query_row(
"SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb,
current_urls, current_landings, current_api_tokens, current_storage_mb
FROM quotas WHERE user_id = ?1;",
params![user_id],
|row| {
Ok(UserQuotas {
user_id: row.get(0)?,
max_urls: row.get(1)?,
max_landings: row.get(2)?,
max_api_tokens: row.get(3)?,
max_storage_mb: row.get(4)?,
current_urls: row.get(5)?,
current_landings: row.get(6)?,
current_api_tokens: row.get(7)?,
current_storage_mb: row.get(8)?,
})
},
)
.optional()
}
pub fn update_user_quotas(
conn: &Connection,
user_id: i64,
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET max_urls = ?1, max_landings = ?2, max_api_tokens = ?3, max_storage_mb = ?4
WHERE user_id = ?5;",
params![max_urls, max_landings, max_api_tokens, max_storage_mb, user_id],
)?;
Ok(())
}
pub fn increment_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = current_urls + 1 WHERE user_id = ?1;",
"landings" => {
"UPDATE quotas SET current_landings = current_landings + 1 WHERE user_id = ?1;"
}
"api_tokens" => {
"UPDATE quotas SET current_api_tokens = current_api_tokens + 1 WHERE user_id = ?1;"
}
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn decrement_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = MAX(0, current_urls - 1) WHERE user_id = ?1;",
"landings" => "UPDATE quotas SET current_landings = MAX(0, current_landings - 1) WHERE user_id = ?1;",
"api_tokens" => "UPDATE quotas SET current_api_tokens = MAX(0, current_api_tokens - 1) WHERE user_id = ?1;",
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn update_quota_storage(
conn: &Connection,
user_id: i64,
storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET current_storage_mb = ?1 WHERE user_id = ?2;",
params![storage_mb, user_id],
)?;
Ok(())
}
// --- API Token Operations ---
pub fn create_user_api_token(
conn: &Connection,
user_id: i64,
token_hash: &str,
) -> rusqlite::Result<UserApiToken> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
params![user_id, token_hash, created_at],
)?;
let id = conn.last_insert_rowid();
// Increment api token counter
let _ = increment_quota_counter(conn, user_id, "api_tokens");
Ok(UserApiToken {
id,
user_id,
token_hash: token_hash.to_string(),
created_at,
})
}
pub fn list_user_api_tokens(
conn: &Connection,
user_id: i64,
) -> rusqlite::Result<Vec<UserApiToken>> {
let mut stmt = conn.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY id DESC;",
)?;
let rows = stmt.query_map(params![user_id], |row| {
Ok(UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})?;
let mut tokens = Vec::new();
for t in rows {
tokens.push(t?);
}
Ok(tokens)
}
pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqlite::Result<()> {
let deleted = conn.execute(
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
params![id, user_id],
)?;
if deleted > 0 {
let _ = decrement_quota_counter(conn, user_id, "api_tokens");
}
Ok(())
}
// --- Global Slug & Quota Reconciliation Helpers ---
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
// 1. Check reserved list
let reserved: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
if reserved {
return Ok(false);
}
// 2. Check global slugs
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
Ok(!exists)
}
pub fn register_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
target_type: &str,
target_id: &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"],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, NULL, ?2, 'created', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn release_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [slug])?;
// Insert history
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],
)?;
Ok(())
}
pub fn soft_delete_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;",
rusqlite::params![now, slug],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'deleted', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn reconcile_user_quotas(
users_conn: &Connection,
user_id: i64,
content_conn: &Connection,
) -> rusqlite::Result<()> {
let urls_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))
.unwrap_or(0);
let landings_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0))
.unwrap_or(0);
let api_tokens_count: i64 = users_conn
.query_row(
"SELECT COUNT(*) FROM api_tokens WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap_or(0);
users_conn.execute(
"UPDATE quotas SET current_urls = ?1, current_landings = ?2, current_api_tokens = ?3 WHERE user_id = ?4;",
rusqlite::params![urls_count, landings_count, api_tokens_count, user_id],
)?;
Ok(())
}
+60 -13
View File
@@ -1,6 +1,6 @@
use axum::{
response::{IntoResponse, Response},
http::StatusCode,
response::{IntoResponse, Response},
};
use std::fmt;
use std::path::PathBuf;
@@ -12,17 +12,36 @@ use std::path::PathBuf;
#[derive(Debug)]
pub enum DatabaseInitError {
/// Data directory could not be created or accessed
DataDirCreate { path: PathBuf, source: std::io::Error },
DataDirCreate {
path: PathBuf,
source: std::io::Error,
},
/// SQLite connection could not be opened
ConnectionOpen { database: String, path: PathBuf, source: rusqlite::Error },
ConnectionOpen {
database: String,
path: PathBuf,
source: rusqlite::Error,
},
/// PRAGMA configuration failed (WAL, foreign_keys, etc.)
PragmaConfig { database: String, pragma: String, source: rusqlite::Error },
PragmaConfig {
database: String,
pragma: String,
source: rusqlite::Error,
},
/// Migration execution failed
MigrationFailed { database: String, version: u32, name: String, source: Box<dyn std::error::Error + Send + Sync> },
MigrationFailed {
database: String,
version: u32,
name: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
/// Database integrity check failed
IntegrityCheckFailed { database: String, message: String },
/// WAL mode could not be enabled (returned unexpected mode)
WalModeFailed { database: String, actual_mode: String },
WalModeFailed {
database: String,
actual_mode: String,
},
}
impl fmt::Display for DatabaseInitError {
@@ -31,20 +50,48 @@ impl fmt::Display for DatabaseInitError {
Self::DataDirCreate { path, source } => {
write!(f, "Failed to create data directory {:?}: {}", path, source)
}
Self::ConnectionOpen { database, path, source } => {
write!(f, "Failed to open {}.db at {:?}: {}", database, path, source)
Self::ConnectionOpen {
database,
path,
source,
} => {
write!(
f,
"Failed to open {}.db at {:?}: {}",
database, path, source
)
}
Self::PragmaConfig { database, pragma, source } => {
Self::PragmaConfig {
database,
pragma,
source,
} => {
write!(f, "PRAGMA {} failed on {}.db: {}", pragma, database, source)
}
Self::MigrationFailed { database, version, name, source } => {
write!(f, "Migration v{} ({}) failed on {}.db: {}", version, name, database, source)
Self::MigrationFailed {
database,
version,
name,
source,
} => {
write!(
f,
"Migration v{} ({}) failed on {}.db: {}",
version, name, database, source
)
}
Self::IntegrityCheckFailed { database, message } => {
write!(f, "Integrity check failed on {}.db: {}", database, message)
}
Self::WalModeFailed { database, actual_mode } => {
write!(f, "WAL mode not enabled on {}.db (got '{}')", database, actual_mode)
Self::WalModeFailed {
database,
actual_mode,
} => {
write!(
f,
"WAL mode not enabled on {}.db (got '{}')",
database, actual_mode
)
}
}
}
+35 -18
View File
@@ -1,32 +1,52 @@
use std::time::Duration;
use tracing::{info, error};
use tracing::{error, info};
use crate::db::Db;
use super::{log_job_end, log_job_start};
use crate::analytics::aggregate_day;
use super::{log_job_start, log_job_end};
use crate::db::Db;
pub async fn run_aggregator(db: Db, interval_mins: u64) {
loop {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background analytics aggregator...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(_) => continue,
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => continue,
};
rows.filter_map(|r| r.ok()).collect()
};
let job_id = log_job_start(&db.system, "analytics_aggregator");
match perform_aggregation(&db).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
Err(e) => {
let err_str = e.to_string();
error!("Error performing aggregation: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
let mut failed = false;
let mut err_msg = None;
for user_id in user_ids {
if let Err(e) = perform_aggregation(&db, user_id).await {
failed = true;
err_msg = Some(e.to_string());
}
}
if failed {
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
error!("Error performing aggregation: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
} else {
log_job_end(&db.system, &job_id, "success", None);
}
}
}
pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Error>> {
let date_range = {
let conn = db.analytics.lock().unwrap();
crate::db::analytics::get_visits_date_range(&conn)?
};
pub async fn perform_aggregation(db: &Db, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let mut conn = super::open_user_analytics_conn(db, user_id)?;
let date_range = crate::db::analytics::get_visits_date_range(&conn)?;
if let Some((min_date, max_date)) = date_range {
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?;
@@ -35,10 +55,7 @@ pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Erro
let mut curr = min;
while curr <= max {
let date_str = curr.format("%Y-%m-%d").to_string();
{
let mut conn = db.analytics.lock().unwrap();
aggregate_day(&mut conn, &date_str)?;
}
aggregate_day(&mut conn, &date_str)?;
if curr == max {
break;
}
+53 -15
View File
@@ -1,8 +1,8 @@
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use super::{log_job_end, log_job_start};
use crate::config::Config;
use super::{log_job_start, log_job_end};
use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_backup_scheduler(db: Db, config: Config) {
if !config.backup_enabled {
@@ -10,7 +10,10 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
return;
}
info!("Starting background backup scheduler (interval: {} mins)...", config.backup_interval_mins);
info!(
"Starting background backup scheduler (interval: {} mins)...",
config.backup_interval_mins
);
loop {
// Run backup every configured interval
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
@@ -31,13 +34,16 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
}
}
pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn std::error::Error>> {
use std::fs::File;
pub async fn perform_backup(
db: &Db,
config: &Config,
) -> Result<String, Box<dyn std::error::Error>> {
use chrono::Utc;
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
use chrono::Utc;
use rusqlite::params;
use std::fs::File;
use tar::Builder;
use uuid::Uuid;
let out_dir = config.backup_dir.clone();
@@ -45,6 +51,36 @@ pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn
std::fs::create_dir_all(&out_dir)?;
}
// Force checkpoint on all databases to flush WAL contents to the main DB files
if let Ok(conn) = db.admin.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.content.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.analytics.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.system.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.users.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
let user_ids: Vec<i64> = rows.filter_map(|r| r.ok()).collect();
for user_id in user_ids {
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) {
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
}
}
}
}
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
let tar_name = format!("{}-bzod-backup.tar.gz", date_str);
let tar_path = out_dir.join(tar_name);
@@ -53,12 +89,14 @@ pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn
let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
for f in files {
let db_file = config.data_dir.join(f);
if db_file.exists() {
tar.append_path_with_name(&db_file, f)?;
}
let admin_dir = config.data_dir.join("admin");
if admin_dir.exists() {
tar.append_dir_all("admin", &admin_dir)?;
}
let users_dir = config.data_dir.join("users");
if users_dir.exists() {
tar.append_dir_all("users", &users_dir)?;
}
tar.into_inner()?.finish()?;
+41
View File
@@ -0,0 +1,41 @@
use crate::db::Db;
use std::time::Duration;
use tracing::info;
/// Background job that marks expired URLs.
///
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
/// gets flipped to `expired = 1`.
pub async fn run_expiry_checker(db: Db) {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(_) => continue,
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => continue,
};
rows.filter_map(|r| r.ok()).collect()
};
let mut total_expired = 0;
for user_id in user_ids {
if let Ok(conn) = super::open_user_content_conn(&db, user_id) {
let count = crate::db::content::expire_urls(&conn).unwrap_or(0);
total_expired += count;
}
}
if total_expired > 0 {
info!(
expired_count = total_expired,
"Expired URLs marked across users"
);
}
}
}
+109 -42
View File
@@ -1,17 +1,18 @@
use reqwest::Client;
use std::time::Duration;
use tracing::{info, error};
use uuid::Uuid;
use chrono::Utc;
use reqwest::Client;
use rusqlite::params;
use std::time::{Duration, Instant};
use tracing::{error, info};
use uuid::Uuid;
use super::{log_job_end, log_job_start};
use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_link_checker(db: Db, interval_mins: u64) {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("bzod-link-checker/0.1")
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.unwrap_or_default();
@@ -32,63 +33,129 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
}
}
pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn std::error::Error>> {
let urls = {
let conn = db.content.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)?
pub async fn perform_link_check(
db: &Db,
client: &Client,
) -> Result<(), Box<dyn std::error::Error>> {
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM users;")?;
let rows = stmt.query_map([], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
for (id, dest) in urls {
let (status, status_code, err_msg) = check_url_health(client, &dest).await;
{
let conn = db.content.lock().unwrap();
crate::db::content::update_url_health(&conn, &id, &status)?;
}
for user_id in user_ids {
let conn = match super::open_user_content_conn(db, user_id) {
Ok(c) => c,
Err(_) => continue,
};
// Log to system.db.health_checks
{
let conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
let urls = crate::db::content::list_urls_for_health_check(&conn)?;
// Rate limiting sleep between external requests
tokio::time::sleep(Duration::from_millis(200)).await;
for (id, dest) in urls {
let (status, detail_status, status_code, latency_ms, err_msg) =
check_url_health(client, &dest).await;
{
crate::db::content::update_url_health_extended(
&conn,
&id,
&status,
&detail_status,
Some(latency_ms),
)?;
}
// Log to system.db.health_checks
{
let sys_conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = sys_conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
// Rate limiting sleep between external requests
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
Ok(())
}
async fn check_url_health(client: &Client, url: &str) -> (String, Option<u16>, Option<String>) {
let res = client.get(url)
.timeout(Duration::from_secs(5))
.send()
.await;
/// Check URL health with detailed classification and latency measurement.
///
/// Returns: (general_status, detail_status, status_code, latency_ms, error_message)
async fn check_url_health(
client: &Client,
url: &str,
) -> (String, String, Option<u16>, i64, Option<String>) {
let start = Instant::now();
let res = client.get(url).timeout(Duration::from_secs(5)).send().await;
let latency_ms = start.elapsed().as_millis() as i64;
match res {
Ok(response) => {
let status = response.status();
let code = status.as_u16();
if status.is_success() || status.is_redirection() {
("healthy".to_string(), Some(code), None)
} else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
("dead".to_string(), Some(code), Some(format!("HTTP {}", code)))
(
"healthy".to_string(),
"healthy".to_string(),
Some(code),
latency_ms,
None,
)
} else if status == reqwest::StatusCode::NOT_FOUND
|| status == reqwest::StatusCode::GONE
{
(
"dead".to_string(),
"dead".to_string(),
Some(code),
latency_ms,
Some(format!("HTTP {}", code)),
)
} else {
("suspect".to_string(), Some(code), Some(format!("HTTP {}", code)))
(
"suspect".to_string(),
format!("http_{}", code),
Some(code),
latency_ms,
Some(format!("HTTP {}", code)),
)
}
}
Err(err) => {
let err_str = err.to_string();
if err.is_timeout() || err.is_connect() {
("suspect".to_string(), None, Some(err_str))
let detail = if err.is_timeout() {
"timeout".to_string()
} else if err.is_connect() {
if err_str.contains("dns") || err_str.contains("resolve") {
"dns_failure".to_string()
} else if err_str.contains("tls")
|| err_str.contains("ssl")
|| err_str.contains("certificate")
{
"tls_error".to_string()
} else {
"connection_refused".to_string()
}
} else if err.is_redirect() {
"redirect_loop".to_string()
} else {
("dead".to_string(), None, Some(err_str))
}
"unknown_error".to_string()
};
let general = if err.is_timeout() || err.is_connect() {
"suspect"
} else {
"dead"
};
(general.to_string(), detail, None, latency_ms, Some(err_str))
}
}
}
+45 -7
View File
@@ -1,16 +1,19 @@
use std::sync::Mutex;
use rusqlite::{Connection, params};
use uuid::Uuid;
use crate::db::Db;
use chrono::Utc;
use rusqlite::{params, Connection};
use std::sync::Mutex;
use uuid::Uuid;
pub mod healthcheck;
pub mod retention;
pub mod aggregate;
pub mod backup;
pub mod expiry;
pub mod healthcheck;
pub mod retention;
pub use healthcheck::{run_link_checker, perform_link_check};
pub use aggregate::{perform_aggregation, run_aggregator};
pub use expiry::run_expiry_checker;
pub use healthcheck::{perform_link_check, run_link_checker};
pub use retention::run_retention_cleaner;
pub use aggregate::{run_aggregator, perform_aggregation};
pub fn log_job_start(conn: &Mutex<Connection>, job_name: &str) -> String {
let id = Uuid::new_v4().to_string();
@@ -33,3 +36,38 @@ pub fn log_job_end(conn: &Mutex<Connection>, id: &str, status: &str, err_msg: Op
);
}
}
pub mod quota_reconcile;
pub use quota_reconcile::run_quota_reconciliation;
// --- Database Connection Helpers for User-specific Databases ---
pub fn open_user_content_conn(
db: &Db,
user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("content.db");
let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
Ok(conn)
}
pub fn open_user_analytics_conn(
db: &Db,
user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("analytics.db");
let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
Ok(conn)
}
+42
View File
@@ -0,0 +1,42 @@
use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
loop {
// Sleep first
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
info!("Running background quota reconciliation...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare select user IDs: {:?}", e);
continue;
}
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(e) => {
error!("Failed to query user IDs: {:?}", e);
continue;
}
};
rows.filter_map(|r| r.ok()).collect()
};
let users_conn = db.users.lock().unwrap();
for user_id in user_ids {
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
if let Err(e) =
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
{
error!("Failed to reconcile quotas for user {}: {:?}", user_id, e);
}
}
}
info!("Quota reconciliation finished.");
}
}
+44 -12
View File
@@ -1,8 +1,8 @@
use std::time::Duration;
use tracing::{info, error};
use tracing::{error, info};
use super::{log_job_end, log_job_start};
use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
let retention_days = match retention_days_opt {
@@ -15,18 +15,50 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
info!("Running background data retention cleanup...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(_) => continue,
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => continue,
};
rows.filter_map(|r| r.ok()).collect()
};
let job_id = log_job_start(&db.system, "retention_cleaner");
let conn = db.analytics.lock().unwrap();
match crate::db::analytics::retention_cleanup(&conn, retention_days) {
Ok(count) => {
info!("Cleaned up {} expired visits from database", count);
log_job_end(&db.system, &job_id, "success", None);
}
Err(e) => {
let err_str = e.to_string();
error!("Error running retention cleaner: {:?}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
let mut total_cleaned = 0;
let mut failed = false;
let mut err_msg = None;
for user_id in user_ids {
match super::open_user_analytics_conn(&db, user_id) {
Ok(conn) => match crate::db::analytics::retention_cleanup(&conn, retention_days) {
Ok(count) => total_cleaned += count,
Err(e) => {
failed = true;
err_msg = Some(e.to_string());
}
},
Err(e) => {
failed = true;
err_msg = Some(e.to_string());
}
}
}
if failed {
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
error!("Error running retention cleaner: {:?}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
} else {
info!(
"Cleaned up {} expired visits across all user databases",
total_cleaned
);
log_job_end(&db.system, &job_id, "success", None);
}
}
}
+10 -10
View File
@@ -1,14 +1,14 @@
pub mod analytics;
pub mod auth;
pub mod charts;
pub mod cli;
pub mod config;
pub mod db;
pub mod auth;
pub mod analytics;
pub mod jobs;
pub mod services;
pub mod utils;
pub mod models;
pub mod templates;
pub mod charts;
pub mod state;
pub mod error;
pub mod jobs;
pub mod models;
pub mod services;
pub mod state;
pub mod templates;
pub mod utils;
pub mod web;
pub mod cli;
+60 -4
View File
@@ -1,20 +1,26 @@
use bzod::cli::{Cli, Commands};
use bzod::config::Config;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use bzod::config::Config;
use bzod::cli::{Cli, Commands};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Set up tracing subscriber
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let config = Config::load();
match cli.command {
Commands::Serve { host, port, data_dir } => {
Commands::Serve {
host,
port,
data_dir,
} => {
bzod::cli::serve::run(host, port, data_dir, config).await?;
}
Commands::Backup { out, data_dir } => {
@@ -38,6 +44,56 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Doctor { data_dir } => {
bzod::cli::doctor::run(data_dir, config).await?;
}
Commands::Shorten {
target_url,
slug,
data_dir,
} => {
bzod::cli::shorten::run(target_url, slug, data_dir, config).await?;
}
Commands::Expand { code, data_dir } => {
bzod::cli::expand::run(code, data_dir, config).await?;
}
Commands::CreateUser {
username,
password,
data_dir,
} => {
bzod::cli::create_user::run(username, password, data_dir, config).await?;
}
Commands::DeleteUser {
user_id,
force,
data_dir,
} => {
bzod::cli::delete_user::run(user_id, force, data_dir, config).await?;
}
Commands::DisableUser { user_id, data_dir } => {
bzod::cli::disable_user::run(user_id, data_dir, config).await?;
}
Commands::EnableUser { user_id, data_dir } => {
bzod::cli::enable_user::run(user_id, data_dir, config).await?;
}
Commands::ResetPassword {
user_id,
password,
data_dir,
} => {
bzod::cli::reset_password::run(user_id, password, data_dir, config).await?;
}
Commands::ListUsers { data_dir } => {
bzod::cli::list_users::run(data_dir, config).await?;
}
Commands::BackupUser {
username,
out,
data_dir,
} => {
bzod::cli::backup_user::run(username, out, data_dir, config).await?;
}
Commands::RestoreUser { file, data_dir } => {
bzod::cli::restore_user::run(file, data_dir, config).await?;
}
}
Ok(())
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ApiKey {
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuditLog {
+11 -8
View File
@@ -1,13 +1,16 @@
pub mod user;
pub mod url;
pub mod page;
pub mod visit;
pub mod api_key;
pub mod audit;
pub mod page;
pub mod url;
pub mod user;
pub mod visit;
pub use user::{User, Session};
pub use url::Url;
pub use page::LandingPage;
pub use visit::{VisitRecord, SummaryEntry};
pub use api_key::ApiKey;
pub use audit::AuditLog;
pub use page::LandingPage;
pub use url::{AuditEvent, LinkPreview, QrCode, Url};
pub use user::{
AccountType, ApiActor, ModerationSeverity, Session, SlugStatus, TenantUser, User, UserApiToken,
UserQuotas, UserSession, UsernameHistory,
};
pub use visit::{SummaryEntry, VisitRecord};
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LandingPage {
+64 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Url {
@@ -11,4 +11,67 @@ pub struct Url {
pub created_at: String,
pub updated_at: String,
pub tags: Vec<String>,
// --- Feature Expansion Fields ---
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default)]
pub expired: bool,
#[serde(skip_serializing)]
pub password_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_latency_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_access_count: Option<i64>,
#[serde(default)]
pub access_count: i64,
}
impl Url {
/// Returns true if this URL has a password set.
pub fn is_password_protected(&self) -> bool {
self.password_hash.is_some()
}
/// Returns true if this URL has reached its access limit.
pub fn is_access_exhausted(&self) -> bool {
if let Some(max) = self.max_access_count {
self.access_count >= max
} else {
false
}
}
}
/// Link preview metadata for smart landing pages.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LinkPreview {
pub id: String,
pub url_id: String,
pub title: Option<String>,
pub description: Option<String>,
pub logo_url: Option<String>,
pub button_text: String,
}
/// QR code metadata record.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct QrCode {
pub id: String,
pub url_id: String,
pub style: String,
pub created_at: String,
}
/// Audit event record for the enhanced audit trail.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuditEvent {
pub id: String,
pub actor: String,
pub action: String,
pub object_type: String,
pub object_id: String,
pub timestamp: String,
pub metadata: Option<String>,
}
+191 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
@@ -15,3 +15,193 @@ pub struct Session {
pub expires_at: String,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TenantUser {
pub id: i64,
pub username: String,
pub password_hash: String,
pub status: String, // 'active', 'disabled', 'suspended', 'pending', 'deleted'
pub created_at: String,
pub last_login: Option<String>,
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
pub organization_id: Option<i64>,
pub metadata: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserQuotas {
pub user_id: i64,
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
pub current_urls: i64,
pub current_landings: i64,
pub current_api_tokens: i64,
pub current_storage_mb: i64,
}
impl UserQuotas {
pub fn urls_pct(&self) -> f64 {
if self.max_urls <= 0 {
0.0
} else {
(self.current_urls as f64 / self.max_urls as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn landings_pct(&self) -> f64 {
if self.max_landings <= 0 {
0.0
} else {
(self.current_landings as f64 / self.max_landings as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn api_tokens_pct(&self) -> f64 {
if self.max_api_tokens <= 0 {
0.0
} else {
(self.current_api_tokens as f64 / self.max_api_tokens as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn storage_pct(&self) -> f64 {
if self.max_storage_mb <= 0 {
0.0
} else {
(self.current_storage_mb as f64 / self.max_storage_mb as f64 * 100.0).clamp(0.0, 100.0)
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserApiToken {
pub id: i64,
pub user_id: i64,
pub token_hash: String,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserSession {
pub id: String,
pub user_id: i64,
pub expires_at: String,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UsernameHistory {
pub id: i64,
pub user_id: i64,
pub old_username: String,
pub new_username: String,
pub changed_at: String,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum SlugStatus {
Active,
Flagged,
Disabled,
SoftDeleted,
}
impl SlugStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Flagged => "flagged",
Self::Disabled => "disabled",
Self::SoftDeleted => "soft_deleted",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"active" => Some(Self::Active),
"flagged" => Some(Self::Flagged),
"disabled" => Some(Self::Disabled),
"soft_deleted" => Some(Self::SoftDeleted),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum AccountType {
System,
Admin,
Standard,
Organization,
Service,
}
impl AccountType {
pub fn as_str(&self) -> &'static str {
match self {
Self::System => "system",
Self::Admin => "admin",
Self::Standard => "standard",
Self::Organization => "organization",
Self::Service => "service",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"system" => Some(Self::System),
"admin" => Some(Self::Admin),
"standard" => Some(Self::Standard),
"organization" => Some(Self::Organization),
"service" => Some(Self::Service),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModerationSeverity {
Low,
Medium,
High,
Critical,
}
impl ModerationSeverity {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Critical => "critical",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"critical" => Some(Self::Critical),
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub enum ApiActor {
Admin(User),
User(TenantUser),
}
impl ApiActor {
pub fn username(&self) -> &str {
match self {
Self::Admin(u) => &u.username,
Self::User(u) => &u.username,
}
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct VisitRecord {
@@ -12,6 +12,7 @@ pub struct VisitRecord {
pub accept_language: String,
pub country: String,
pub status_code: u16,
pub owner_user_id: Option<i64>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db;
use crate::models::ApiKey;
use crate::error::AppError;
use crate::models::ApiKey;
pub fn create_api_key(
db: &Db,
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db;
use crate::models::AuditLog;
use crate::error::AppError;
use crate::models::AuditLog;
pub fn log_action(
db: &Db,
+49
View File
@@ -0,0 +1,49 @@
use crate::models::Url;
use crate::services::qr::{generate_qr_png, generate_qr_svg};
use std::io::Write;
use zip::write::FileOptions;
use zip::ZipWriter;
/// Export QR codes for the given URLs as a ZIP file.
/// `format` can be "png" or "svg".
/// `base_url` is used to build the full short URL encoded in the QR.
pub fn export_qr_zip(
urls: &[Url],
format: &str,
base_url: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut buf = Vec::new();
{
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut buf));
let options =
FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated);
let mut csv_content = String::from("code,destination,qr_filename\n");
for url in urls {
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), url.code);
let ext = if format == "svg" { "svg" } else { "png" };
let filename = format!("{}.{}", url.code, ext);
let qr_data = if format == "svg" {
generate_qr_svg(&full_url)?.into_bytes()
} else {
generate_qr_png(&full_url, 256)?
};
zip.start_file(&filename, options)?;
zip.write_all(&qr_data)?;
// Escape quotes in destination for CSV formatting
let escaped_dest = url.destination.replace('"', "\"\"");
csv_content.push_str(&format!("{},\"{}\",{}\n", url.code, escaped_dest, filename));
}
zip.start_file("manifest.csv", options)?;
zip.write_all(csv_content.as_bytes())?;
zip.finish()?;
}
Ok(buf)
}
+3 -2
View File
@@ -1,6 +1,6 @@
use crate::db::Db;
use crate::models::LandingPage;
use crate::error::AppError;
use crate::models::LandingPage;
pub fn create_landing_page(
db: &Db,
@@ -11,7 +11,8 @@ pub fn create_landing_page(
state: &str,
) -> Result<LandingPage, AppError> {
let conn = db.content.lock().unwrap();
let page = crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
let page =
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
Ok(page)
}
+4 -2
View File
@@ -1,4 +1,6 @@
pub mod shortener;
pub mod landing_pages;
pub mod api_keys;
pub mod audit;
pub mod bulk;
pub mod landing_pages;
pub mod qr;
pub mod shortener;
+58
View File
@@ -0,0 +1,58 @@
//! QR code generation service.
//!
//! Generates QR codes on-demand as PNG or SVG. No files are stored on disk.
use image::Luma;
use qrcode::QrCode;
use std::io::Cursor;
/// Generate a QR code as a PNG byte vector.
///
/// The `url` is encoded into the QR matrix. The `size` parameter controls
/// the pixel dimensions of the output image (default: 256).
pub fn generate_qr_png(url: &str, size: u32) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let code = QrCode::new(url.as_bytes())?;
let image = code.render::<Luma<u8>>().min_dimensions(size, size).build();
let mut buf = Vec::new();
let mut cursor = Cursor::new(&mut buf);
image.write_to(&mut cursor, image::ImageFormat::Png)?;
Ok(buf)
}
/// Generate a QR code as an SVG string.
pub fn generate_qr_svg(url: &str) -> Result<String, Box<dyn std::error::Error>> {
let code = QrCode::new(url.as_bytes())?;
let svg = code
.render::<qrcode::render::svg::Color>()
.min_dimensions(256, 256)
.build();
Ok(svg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qr_png_generation() {
let png = generate_qr_png("https://bzo.in/abc123", 256).unwrap();
assert!(!png.is_empty());
// PNG magic bytes
assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
}
#[test]
fn test_qr_svg_generation() {
let svg = generate_qr_svg("https://bzo.in/abc123").unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn test_qr_long_url() {
let long_url = format!("https://example.com/{}", "a".repeat(500));
let png = generate_qr_png(&long_url, 512).unwrap();
assert!(!png.is_empty());
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db;
use crate::models::Url;
use crate::error::AppError;
use crate::models::Url;
pub fn create_url(
db: &Db,
+74 -4
View File
@@ -1,9 +1,17 @@
use crate::analytics::queue::AnalyticsQueue;
use crate::config::Config;
use crate::db::Db;
use rusqlite::Connection;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use rusqlite::Connection;
use crate::config::Config;
use crate::analytics::queue::AnalyticsQueue;
use crate::db::Db;
#[derive(Clone)]
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
#[derive(Clone)]
pub struct AppState {
@@ -11,6 +19,8 @@ pub struct AppState {
pub content_db: Arc<Mutex<Connection>>,
pub analytics_db: Arc<Mutex<Connection>>,
pub system_db: Arc<Mutex<Connection>>,
pub users_db: Arc<Mutex<Connection>>,
pub user_dbs: Arc<Mutex<HashMap<i64, UserDbs>>>,
pub db: Db,
pub config: Config,
pub analytics_queue: AnalyticsQueue,
@@ -18,11 +28,71 @@ pub struct AppState {
}
impl AppState {
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
let mut pool = self.user_dbs.lock().unwrap();
if let Some(dbs) = pool.get(&user_id) {
return Ok(dbs.clone());
}
// Open connection and run migrations
let user_dir = self.config.data_dir.join("users").join(user_id.to_string());
std::fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
crate::db::sqlite::enable_wal(&content_conn, "content")?;
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
// Run migrations
crate::db::migrations::run_migrations(
&mut content_conn,
"content",
crate::db::migrations::CONTENT_MIGRATIONS,
Some(&self.system_db),
)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
crate::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
crate::db::migrations::ANALYTICS_MIGRATIONS,
Some(&self.system_db),
)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
let dbs = UserDbs {
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
profile: Arc::new(Mutex::new(profile_conn)),
};
pool.insert(user_id, dbs.clone());
Ok(dbs)
}
pub fn db_compact(&self) -> Result<(), rusqlite::Error> {
self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
self.content_db.lock().unwrap().execute("VACUUM;", [])?;
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
self.system_db.lock().unwrap().execute("VACUUM;", [])?;
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
Ok(())
}
}
+106
View File
@@ -0,0 +1,106 @@
use crate::models::{LandingPage, Url};
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Clone, Debug)]
pub struct VisitorLogEntry {
pub sr: usize,
pub timestamp: String,
pub ip_address: String,
pub country: String,
pub referrer: String,
pub browser: String,
pub user_agent: String,
pub utm_source: String,
pub utm_campaign: String,
}
#[derive(Template)]
#[template(path = "url_analytics.html")]
pub struct UrlAnalyticsTemplate {
pub admin_username: String,
pub url: Url,
pub total_clicks: i64,
pub unique_visitors: i64,
pub qr_scans: i64,
pub direct_clicks: i64,
pub traffic_chart: String,
pub monthly_chart: String,
pub countries_chart: String,
pub referrers_chart: String,
pub browsers_chart: String,
// Paginated visitor logs
pub visits: Vec<VisitorLogEntry>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
pub total_records: i64,
pub page_start: usize,
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
impl UrlAnalyticsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UrlAnalyticsTemplate {
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 = "page_analytics.html")]
pub struct PageAnalyticsTemplate {
pub admin_username: String,
pub page: LandingPage,
pub total_views: i64,
pub unique_visitors: i64,
pub traffic_chart: String,
pub monthly_chart: String,
pub countries_chart: String,
pub referrers_chart: String,
// Paginated visitor logs
pub visits: Vec<VisitorLogEntry>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
pub total_records: i64,
pub page_start: usize,
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
impl PageAnalyticsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for PageAnalyticsTemplate {
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(),
}
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
@@ -23,7 +23,11 @@ impl IntoResponse for DashboardTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+397 -10
View File
@@ -1,33 +1,420 @@
pub mod analytics;
pub mod dashboard;
pub mod urls;
pub mod pages;
pub mod stats;
pub mod settings;
pub mod stats;
pub mod urls;
pub mod user_dashboard;
pub mod user_pages;
pub mod user_settings;
pub mod user_urls;
pub mod users;
pub use dashboard::DashboardTemplate;
pub use urls::UrlsTemplate;
pub use pages::PagesTemplate;
pub use stats::{StatusTemplate, AuditTemplate};
pub use settings::SettingsTemplate;
pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry};
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
pub use dashboard::DashboardTemplate;
pub use pages::PagesTemplate;
pub use settings::SettingsTemplate;
pub use stats::{AuditTemplate, StatusTemplate, UserAuditTemplate, UserStatusTemplate};
pub use urls::UrlsTemplate;
pub use user_dashboard::UserDashboardTemplate;
pub use user_pages::UserPagesTemplate;
pub use user_settings::UserSettingsTemplate;
pub use user_urls::UserUrlsTemplate;
pub use users::UsersTemplate;
#[derive(Template)]
#[template(path = "login.html")]
pub struct LoginTemplate {
pub error: Option<String>,
pub csrf_token: String,
pub action: String,
pub title: String,
pub subtitle: String,
pub button_text: String,
}
impl IntoResponse for LoginTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "gate.html")]
pub struct GateTemplate {
pub code: String,
pub error: Option<String>,
}
impl IntoResponse for GateTemplate {
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 = "preview.html")]
pub struct PreviewTemplate {
pub code: String,
pub title: Option<String>,
pub description: Option<String>,
pub logo_url: Option<String>,
pub button_text: String,
pub destination: String,
}
impl IntoResponse for PreviewTemplate {
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 = "users_new.html")]
pub struct UsersNewTemplate {
pub admin_username: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UsersNewTemplate {
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_detail.html")]
pub struct UserDetailTemplate {
pub admin_username: String,
pub target_user: crate::models::TenantUser,
pub stats: crate::web::admin::UserDetailStats,
pub sessions: Vec<crate::models::UserSession>,
pub tokens: Vec<crate::models::UserApiToken>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserDetailTemplate {
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_edit.html")]
pub struct UserEditTemplate {
pub admin_username: String,
pub target_user: crate::models::TenantUser,
pub quotas: crate::models::UserQuotas,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserEditTemplate {
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 = "moderation.html")]
pub struct ModerationTemplate {
pub admin_username: String,
pub flagged_items: Vec<crate::web::admin::GlobalSlugRow>,
pub logs: Vec<crate::web::admin::ModerationLogEntry>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for ModerationTemplate {
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 = "slugs.html")]
pub struct SlugsTemplate {
pub admin_username: String,
pub slugs: Vec<crate::web::admin::GlobalSlugRow>,
pub history: Vec<crate::web::admin::SlugHistoryRow>,
pub csrf_token: String,
pub search_filter: Option<String>,
pub owner_filter: Option<i64>,
pub status_filter: Option<String>,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for SlugsTemplate {
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 = "sessions.html")]
pub struct SessionsTemplate {
pub admin_username: String,
pub sessions: Vec<crate::models::UserSession>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for SessionsTemplate {
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 = "quotas.html")]
pub struct QuotasTemplate {
pub admin_username: String,
pub quotas: Vec<crate::models::UserQuotas>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for QuotasTemplate {
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 = "health.html")]
pub struct HealthTemplate {
pub admin_username: String,
pub db_reports: Vec<crate::db::sqlite::DatabaseHealthReport>,
pub total_data_size: String,
pub system_db_size: String,
pub users_db_size: String,
pub admin_db_size: String,
pub tenants_db_size: String,
pub job_history: Vec<crate::web::admin::JobHistoryRow>,
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for HealthTemplate {
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 = "backups.html")]
pub struct BackupsTemplate {
pub admin_username: String,
pub files: Vec<crate::web::admin::BackupFileRow>,
pub history: Vec<crate::web::admin::BackupHistoryRow>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for BackupsTemplate {
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 = "api_tokens.html")]
pub struct ApiTokensTemplate {
pub admin_username: String,
pub username: String,
pub tokens: Vec<crate::models::UserApiToken>,
pub new_token: Option<String>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for ApiTokensTemplate {
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_analytics.html")]
pub struct UserAnalyticsTemplate {
pub admin_username: String,
pub username: String,
pub total_clicks: i64,
pub unique_visitors: i64,
pub direct_clicks: i64,
pub referred_clicks: i64,
pub referrers_chart: String,
pub browsers_chart: String,
pub visits: Vec<crate::models::VisitRecord>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserAnalyticsTemplate {
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_url_analytics.html")]
pub struct UserUrlAnalyticsTemplate {
pub admin_username: String,
pub username: String,
pub url_code: String,
pub destination: String,
pub visits: Vec<VisitorLogEntry>,
}
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<VisitorLogEntry>,
}
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(),
}
}
}
+16 -3
View File
@@ -1,9 +1,9 @@
use crate::models::LandingPage;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use crate::models::LandingPage;
#[derive(Template)]
#[template(path = "pages.html")]
@@ -12,13 +12,26 @@ pub struct PagesTemplate {
pub pages: Vec<LandingPage>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl PagesTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for PagesTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+7 -3
View File
@@ -1,9 +1,9 @@
use crate::models::ApiKey;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use crate::models::ApiKey;
#[derive(Template)]
#[template(path = "settings.html")]
@@ -20,7 +20,11 @@ impl IntoResponse for SettingsTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+60 -4
View File
@@ -1,9 +1,9 @@
use crate::models::AuditLog;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use crate::models::AuditLog;
#[derive(Template)]
#[template(path = "status_ui.html")]
@@ -16,6 +16,7 @@ pub struct StatusTemplate {
pub uptime: String,
pub version: &'static str,
pub git_commit: &'static str,
pub urls: Vec<crate::models::Url>,
}
#[derive(Template)]
@@ -29,7 +30,11 @@ impl IntoResponse for StatusTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
@@ -38,7 +43,58 @@ impl IntoResponse for AuditTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "user_status.html")]
pub struct UserStatusTemplate {
pub admin_username: String,
pub app_status: &'static str,
pub db_status: String,
pub queue_size: usize,
pub memory_usage: String,
pub uptime: String,
pub version: &'static str,
pub git_commit: &'static str,
pub urls: Vec<crate::models::Url>,
}
#[derive(Template)]
#[template(path = "user_audit.html")]
pub struct UserAuditTemplate {
pub admin_username: String,
pub logs: Vec<AuditLog>,
}
impl IntoResponse for UserStatusTemplate {
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(),
}
}
}
impl IntoResponse for UserAuditTemplate {
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(),
}
}
}
+17 -3
View File
@@ -1,9 +1,9 @@
use crate::models::Url;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use crate::models::Url;
#[derive(Template)]
#[template(path = "urls.html")]
@@ -13,13 +13,27 @@ pub struct UrlsTemplate {
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
pub base_url: String,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UrlsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UrlsTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+33
View File
@@ -0,0 +1,33 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_dashboard.html")]
pub struct UserDashboardTemplate {
pub admin_username: String,
pub total_urls: i64,
pub total_pages: i64,
pub total_clicks: i64,
pub active_links: i64,
pub dead_links: i64,
pub traffic_chart: String,
pub countries_chart: String,
pub browsers_chart: String,
pub referrers_chart: String,
}
impl IntoResponse for UserDashboardTemplate {
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(),
}
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::models::LandingPage;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_pages.html")]
pub struct UserPagesTemplate {
pub admin_username: String,
pub username: String,
pub pages: Vec<LandingPage>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserPagesTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserPagesTemplate {
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(),
}
}
}
+28
View File
@@ -0,0 +1,28 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_settings.html")]
pub struct UserSettingsTemplate {
pub admin_username: String,
pub username: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserSettingsTemplate {
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(),
}
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::models::Url;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_urls.html")]
pub struct UserUrlsTemplate {
pub admin_username: String,
pub username: String,
pub urls: Vec<Url>,
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
pub base_url: String,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserUrlsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserUrlsTemplate {
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(),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
use crate::models::TenantUser;
use askama::Template;
use axum::response::{Html, IntoResponse, Response};
#[derive(Template)]
#[template(path = "users.html")]
pub struct UsersTemplate {
pub admin_username: String,
pub users: Vec<TenantUser>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UsersTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more