Compare commits
46
Commits
v0.1.0
...
v0.5.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dfb8c0f1b | ||
|
|
743502b183 | ||
|
|
7ee6ab2feb | ||
|
|
621a1eccdc | ||
|
|
cefb84f643 | ||
|
|
9fae39dfa4 | ||
|
|
2212701b6b | ||
|
|
536d885a3d | ||
|
|
a4ffe9a509 | ||
|
|
ad05af95e9 | ||
|
|
17d8618443 | ||
|
|
74524cb7d2 | ||
|
|
e2140e6614 | ||
|
|
1a9bf096f3 | ||
|
|
900f72a299 | ||
|
|
d42f6da94a | ||
|
|
18d8b16a2c | ||
|
|
f6dcf58b79 | ||
|
|
84c48fa5c1 | ||
|
|
0e111d61ff | ||
|
|
81eb8950dd | ||
|
|
914563e883 | ||
|
|
8b521f1a71 | ||
|
|
f1d72c8cbe | ||
|
|
d9979ba04c | ||
|
|
6f42b43608 | ||
|
|
3e9dc47604 | ||
|
|
e2e61fc412 | ||
|
|
a0a73b918c | ||
|
|
d2174aa111 | ||
|
|
6a45474e97 | ||
|
|
c6486763e3 | ||
|
|
02a26cfd94 | ||
|
|
3c0a1bdd91 | ||
|
|
ee6d3135d5 | ||
|
|
671370571a | ||
|
|
42a2df33dd | ||
|
|
80038fb851 | ||
|
|
9c5e3e4d58 | ||
|
|
27c4ad6805 | ||
|
|
592154e961 | ||
|
|
bcbcc90d98 | ||
|
|
157aa81252 | ||
|
|
a55f40dc29 | ||
|
|
a516ecedb0 | ||
|
|
50718e57f0 |
No files matched your search
@@ -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
@@ -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
|
||||
@@ -5,3 +5,4 @@ data/
|
||||
*.db-shm
|
||||
.env
|
||||
|
||||
.idea/
|
||||
Generated
+790
-2
File diff suppressed because it is too large.
Load diff
+12
-4
@@ -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"
|
||||
+31
-25
@@ -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"]
|
||||
CMD ["serve"]
|
||||
File renamed without changes.
+21
@@ -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.
|
||||
@@ -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)
|
||||
---
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
### URL Management
|
||||
|
||||

|
||||
|
||||
### Landing Pages
|
||||
|
||||

|
||||
|
||||
### Settings
|
||||
|
||||

|
||||
|
||||
### Server Status
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 |
+67
-24
@@ -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<()> {
|
||||
@@ -10,7 +10,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
|
||||
)?;
|
||||
|
||||
|
||||
struct RawVisit {
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
@@ -18,7 +18,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
referer: String,
|
||||
country: String,
|
||||
}
|
||||
|
||||
|
||||
let rows = stmt.query_map(params![date], |row| {
|
||||
Ok(RawVisit {
|
||||
target_type: row.get(0)?,
|
||||
@@ -28,7 +28,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
country: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
for r in rows {
|
||||
visits.push(r?);
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum AnalyticsEvent {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+267
-27
@@ -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> {
|
||||
@@ -22,13 +24,39 @@ pub fn authenticate_session(
|
||||
Some(c) => c,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
|
||||
// Check expiration
|
||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
|
||||
if expires.with_timezone(&Utc) < Utc::now() {
|
||||
@@ -38,43 +66,255 @@ pub fn authenticate_session(
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
let key = auth_header.trim_start_matches("Bearer ").trim();
|
||||
if key.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
||||
// Hash the API key using SHA-256 to compare with stored hash
|
||||
let mut hasher = Sha256::new();
|
||||
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)?;
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
|
||||
return Ok(Some(user));
|
||||
|
||||
// 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()?;
|
||||
|
||||
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,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
+2
-2
@@ -14,7 +14,7 @@ pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
|
||||
let pad_left = 130.0;
|
||||
let pad_right = 70.0;
|
||||
let width = 600.0;
|
||||
|
||||
|
||||
let height = pad_top + pad_bottom + (data.len() as f64 * (bar_height + gap)) - gap;
|
||||
let chart_w = width - pad_left - pad_right;
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
|
||||
for (i, (label, val)) in data.iter().enumerate() {
|
||||
let y = pad_top + (i as f64 * (bar_height + gap));
|
||||
let bar_w = (*val as f64 / max_x) * chart_w;
|
||||
|
||||
|
||||
let pct = if total_count > 0 {
|
||||
(*val as f64 / total_count as f64) * 100.0
|
||||
} else {
|
||||
|
||||
+16
-4
@@ -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,15 +74,23 @@ 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
|
||||
));
|
||||
|
||||
|
||||
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
@@ -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;
|
||||
+9
-5
@@ -1,20 +1,24 @@
|
||||
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)?;
|
||||
|
||||
|
||||
info!("Starting database backup...");
|
||||
let backup_path = perform_backup(&db, &config).await?;
|
||||
info!("Database backup generated successfully: {}", backup_path);
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>,
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+53
-11
@@ -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,12 +61,15 @@ 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();
|
||||
let _ = io::stdin().read_line(&mut confirm);
|
||||
|
||||
|
||||
if !confirm.trim().eq_ignore_ascii_case("y") {
|
||||
info!("Restore cancelled.");
|
||||
return Ok(());
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
+39
-10
@@ -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,16 +13,22 @@ 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);
|
||||
|
||||
// Init DBs
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
|
||||
// Init Queue
|
||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
||||
|
||||
@@ -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,
|
||||
@@ -67,7 +96,7 @@ pub async fn run(
|
||||
let router = create_router(state);
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
|
||||
info!("Listening for requests on http://{}", addr);
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -1,23 +1,30 @@
|
||||
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 ===");
|
||||
println!("Storage Directory: {:?}", config.data_dir);
|
||||
|
||||
|
||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
||||
for f in files {
|
||||
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();
|
||||
|
||||
+9
-7
@@ -1,24 +1,26 @@
|
||||
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)?;
|
||||
|
||||
|
||||
info!("Running one-shot link validation...");
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent("bzod-cli-checker/0.1")
|
||||
.build()?;
|
||||
|
||||
|
||||
crate::jobs::perform_link_check(&db, &client).await?;
|
||||
info!("Link validation complete.");
|
||||
|
||||
|
||||
+86
-27
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-29
@@ -1,17 +1,21 @@
|
||||
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();
|
||||
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![id, username, password_hash, created_at],
|
||||
)?;
|
||||
|
||||
|
||||
Ok(User {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
@@ -21,9 +25,11 @@ 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()? {
|
||||
Ok(Some(User {
|
||||
id: row.get(0)?,
|
||||
@@ -37,9 +43,10 @@ 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()? {
|
||||
Ok(Some(User {
|
||||
id: row.get(0)?,
|
||||
@@ -63,12 +70,22 @@ pub fn create_session(
|
||||
expires_at_rfc3339: &str,
|
||||
) -> 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(),
|
||||
user_id: user_id.to_string(),
|
||||
@@ -78,9 +95,10 @@ 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()? {
|
||||
Ok(Some(Session {
|
||||
id: row.get(0)?,
|
||||
@@ -112,12 +130,12 @@ pub fn create_api_key(
|
||||
) -> rusqlite::Result<ApiKey> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO api_keys (id, user_id, key_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
params![id, user_id, key_hash, name, created_at],
|
||||
)?;
|
||||
|
||||
|
||||
Ok(ApiKey {
|
||||
id,
|
||||
user_id: user_id.to_string(),
|
||||
@@ -133,7 +151,7 @@ pub fn get_api_key_by_hash(conn: &Connection, key_hash: &str) -> rusqlite::Resul
|
||||
"SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE key_hash = ?1;"
|
||||
)?;
|
||||
let mut rows = stmt.query(params![key_hash])?;
|
||||
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(ApiKey {
|
||||
id: row.get(0)?,
|
||||
@@ -162,7 +180,7 @@ pub fn list_api_keys(conn: &Connection, user_id: &str) -> rusqlite::Result<Vec<A
|
||||
last_used_at: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
let mut keys = Vec::new();
|
||||
for key in rows {
|
||||
keys.push(key?);
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -192,13 +213,13 @@ pub fn write_audit_log(
|
||||
) -> rusqlite::Result<AuditLog> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let timestamp = Utc::now().to_rfc3339();
|
||||
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO audit_logs (id, timestamp, username, action, object_type, object_id, ip_address, user_agent)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
params![id, timestamp, username, action, object_type, object_id, ip_address, user_agent],
|
||||
)?;
|
||||
|
||||
|
||||
Ok(AuditLog {
|
||||
id,
|
||||
timestamp,
|
||||
@@ -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 {
|
||||
@@ -228,7 +253,7 @@ pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::
|
||||
user_agent: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
let mut logs = Vec::new();
|
||||
for log in rows {
|
||||
logs.push(log?);
|
||||
@@ -247,7 +272,7 @@ pub fn set_config(conn: &Connection, key: &str, value: &str) -> rusqlite::Result
|
||||
pub fn get_config(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
|
||||
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1;")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
let val: String = row.get(0)?;
|
||||
Ok(Some(val))
|
||||
|
||||
+408
-71
@@ -1,11 +1,11 @@
|
||||
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) {
|
||||
let ua_lower = ua.to_lowercase();
|
||||
|
||||
|
||||
let os = if ua_lower.contains("windows") {
|
||||
"Windows".to_string()
|
||||
} else if ua_lower.contains("macintosh") || ua_lower.contains("mac os x") {
|
||||
@@ -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()
|
||||
@@ -54,15 +59,17 @@ pub fn clean_referrer(referer: &str) -> String {
|
||||
if referer.is_empty() || referer == "direct" {
|
||||
return "Direct".to_string();
|
||||
}
|
||||
|
||||
|
||||
if let Ok(url) = reqwest::Url::parse(referer) {
|
||||
if let Some(host) = url.host_str() {
|
||||
return host.trim_start_matches("www.").to_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,10 +82,10 @@ 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 {
|
||||
stmt.execute(params![
|
||||
r.id,
|
||||
@@ -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)?;
|
||||
@@ -128,7 +145,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
|
||||
)?;
|
||||
|
||||
|
||||
struct RawVisit {
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
@@ -136,7 +153,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
referer: String,
|
||||
country: String,
|
||||
}
|
||||
|
||||
|
||||
let rows = stmt.query_map(params![date], |row| {
|
||||
Ok(RawVisit {
|
||||
target_type: row.get(0)?,
|
||||
@@ -146,7 +163,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
country: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
for r in rows {
|
||||
visits.push(r?);
|
||||
}
|
||||
@@ -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,18 +336,20 @@ 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| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
@@ -301,17 +365,17 @@ pub fn get_clicks_trend_raw(
|
||||
limit_days: i64,
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
|
||||
|
||||
|
||||
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| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
@@ -326,18 +390,18 @@ pub fn get_hourly_trend_raw(
|
||||
limit_days: i64,
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
|
||||
|
||||
|
||||
// SQLite strftime('%H', timestamp) extracts the hour
|
||||
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| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
@@ -355,13 +419,13 @@ 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| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
@@ -378,30 +442,32 @@ pub fn get_metric_rankings_raw(
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
// Falls back to direct query on visits
|
||||
let mut res = Vec::new();
|
||||
|
||||
|
||||
match metric_type {
|
||||
"country" => {
|
||||
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)?;
|
||||
Ok((clean_referrer(&raw_ref), row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
|
||||
// Re-aggregate because clean_referrer might group different referrers
|
||||
let mut grouped: HashMap<String, i64> = HashMap::new();
|
||||
for r in rows {
|
||||
@@ -416,12 +482,12 @@ 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)?))
|
||||
})?;
|
||||
|
||||
|
||||
let mut grouped: HashMap<String, i64> = HashMap::new();
|
||||
for r in rows {
|
||||
let (ua, count) = r?;
|
||||
@@ -439,34 +505,305 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+261
-94
@@ -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<()> {
|
||||
@@ -11,20 +11,20 @@ fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite:
|
||||
if tag_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Insert tag if it doesn't exist
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2);",
|
||||
params![Uuid::new_v4().to_string(), tag_name],
|
||||
)?;
|
||||
|
||||
|
||||
// Get tag id
|
||||
let tag_id: String = conn.query_row(
|
||||
"SELECT id FROM tags WHERE name = ?1;",
|
||||
params![tag_name],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
|
||||
// Insert association
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO url_tags (url_id, tag_id) VALUES (?1, ?2);",
|
||||
@@ -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)
|
||||
}
|
||||
@@ -167,69 +235,60 @@ pub fn list_urls(
|
||||
tag_filter: Option<&str>,
|
||||
) -> rusqlite::Result<Vec<Url>> {
|
||||
let mut urls = Vec::new();
|
||||
|
||||
|
||||
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
|
||||
JOIN url_tags ut ON u.id = ut.url_id
|
||||
JOIN tags t ON ut.tag_id = t.id
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(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
@@ -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';
|
||||
"#,
|
||||
},
|
||||
];
|
||||
+410
-47
@@ -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(())
|
||||
}
|
||||
@@ -115,12 +478,12 @@ mod db_init_tests {
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
let db = Db::init(&config);
|
||||
|
||||
|
||||
// Cleanup
|
||||
if temp_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
|
||||
assert!(db.is_ok(), "Failed to init DB: {:?}", db.err());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-19
@@ -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;
|
||||
}
|
||||
|
||||
+55
-17
@@ -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,12 +10,15 @@ 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;
|
||||
info!("Running background database backup...");
|
||||
|
||||
|
||||
let job_id = log_job_start(&db.system, "database_backup");
|
||||
match perform_backup(&db, &config).await {
|
||||
Ok(path) => {
|
||||
@@ -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,14 +89,16 @@ 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()?;
|
||||
let size_bytes = std::fs::metadata(&tar_path)?.len();
|
||||
let path_str = tar_path.to_string_lossy().to_string();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
-44
@@ -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();
|
||||
|
||||
@@ -19,7 +20,7 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
// Sleep first to give server time to start up
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
info!("Running background link health check...");
|
||||
|
||||
|
||||
let job_id = log_job_start(&db.system, "link_checker");
|
||||
match perform_link_check(&db, &client).await {
|
||||
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
// 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],
|
||||
);
|
||||
}
|
||||
for user_id in user_ids {
|
||||
let conn = match super::open_user_content_conn(db, user_id) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Rate limiting sleep between external requests
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let urls = crate::db::content::list_urls_for_health_check(&conn)?;
|
||||
|
||||
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
@@ -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)
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
+45
-13
@@ -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 {
|
||||
@@ -14,19 +14,51 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
||||
// Check once every 24 hours
|
||||
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
@@ -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
@@ -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,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ApiKey {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AuditLog {
|
||||
|
||||
+11
-8
@@ -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
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LandingPage {
|
||||
|
||||
+64
-1
@@ -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
@@ -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
@@ -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,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,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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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,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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user