Release v0.5.1: namespace integrity, dashboard parity and QR hardening
This commit is contained in:
1 parent
0295b4bd7c
commit
115f6e9a23
45 files changed
+5851
-1765
No files matched your search
Generated
+1
-1
@@ -453,7 +453,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
version = "0.5.0"
|
version = "0.5.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
|||||||
+257
-77
@@ -1,93 +1,273 @@
|
|||||||
# BZOD v0.5.0 — General Availability
|
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening
|
||||||
|
|
||||||
**Release Date:** 2026-06-19
|
**Release Date:** 2026-06-20
|
||||||
|
|
||||||
**BZOD v0.5.0** is the largest release in project history and marks the transition from a single-user URL shortener into a **production-ready multi-user platform** with tenant isolation, administration tools, analytics, moderation, backups, auditing, and comprehensive validation coverage.
|
BZOD v0.5.1 focuses on platform integrity, multi-tenant safety, dashboard parity, QR reliability, and upgrade validation.
|
||||||
|
|
||||||
## Key Highlights
|
While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the foundations required for safe operation at scale.
|
||||||
|
|
||||||
- **Multi-User Architecture** with strong tenant isolation
|
|
||||||
- **Global Slug Namespace** with ownership tracking
|
|
||||||
- **New Administration Portal** for user management and moderation
|
|
||||||
- **User Self-Service Portal** with dedicated dashboards
|
|
||||||
- **Enhanced Analytics** with per-user and platform-wide views
|
|
||||||
- **Comprehensive Backup & Recovery** tooling
|
|
||||||
- **Upgrade Framework** with automated migration validation
|
|
||||||
- **90+ Automated Tests** covering multi-user scenarios, upgrades, and disaster recovery
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Multi-User Architecture
|
# Highlights
|
||||||
|
|
||||||
BZOD now supports multiple isolated users on a single deployment.
|
## Global Slug Registry
|
||||||
|
|
||||||
**Key capabilities:**
|
Introduced a hardened global slug registry to guarantee namespace integrity across the entire platform.
|
||||||
- Per-user content and analytics databases
|
|
||||||
- User quotas and status management
|
|
||||||
- User sessions and API tokens
|
|
||||||
- Strong tenant isolation enforcement
|
|
||||||
|
|
||||||
Each user's data is strictly separated.
|
The following resources can no longer share the same slug:
|
||||||
|
|
||||||
## Administration Portal
|
* Administrator URLs
|
||||||
|
* Administrator Landing Pages
|
||||||
|
* User URLs
|
||||||
|
* User Landing Pages
|
||||||
|
|
||||||
New administrative dashboards include:
|
Duplicate namespace conflicts are automatically detected and blocked.
|
||||||
|
|
||||||
**User Management**
|
|
||||||
- Create, delete, enable, disable users
|
|
||||||
- Password resets and quota management
|
|
||||||
|
|
||||||
**Moderation Tools**
|
|
||||||
- Flag, disable, or re-enable content
|
|
||||||
- Moderation history
|
|
||||||
|
|
||||||
**Session & Audit Management**
|
|
||||||
- View and revoke sessions
|
|
||||||
- Full audit viewer
|
|
||||||
|
|
||||||
**Backup Management**
|
|
||||||
- Create, restore, and download backups
|
|
||||||
|
|
||||||
## User Self-Service Portal
|
|
||||||
|
|
||||||
Standard users now have dedicated dashboards for:
|
|
||||||
- Profile & password management
|
|
||||||
- Link and landing page management
|
|
||||||
- QR code generation
|
|
||||||
- Personal analytics
|
|
||||||
- API token management
|
|
||||||
|
|
||||||
## Security & Reliability
|
|
||||||
|
|
||||||
- Improved authentication and authorization
|
|
||||||
- Enhanced CSRF, SQL injection, and path traversal protection
|
|
||||||
- SQLite WAL mode with better transaction handling
|
|
||||||
- Comprehensive backup & recovery with rollback protection
|
|
||||||
|
|
||||||
## Testing & Validation
|
|
||||||
|
|
||||||
**Passed:**
|
|
||||||
- 90+ automated tests
|
|
||||||
- Full multi-user validation
|
|
||||||
- Upgrade path verification
|
|
||||||
- Backup/Restore + Disaster recovery tests
|
|
||||||
- Security regression tests
|
|
||||||
- Concurrency and WAL recovery tests
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
The internal storage model has changed significantly to support multi-user operation.
|
|
||||||
|
|
||||||
**Administrators upgrading from v0.4.x should review:**
|
|
||||||
- `UPGRADE.md`
|
|
||||||
- `MULTI_USER.md`
|
|
||||||
- `BACKUP_RESTORE.md`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Get Started
|
## Namespace Integrity Validation
|
||||||
|
|
||||||
**One-command installation:**
|
New validation routines now verify:
|
||||||
|
|
||||||
```bash
|
* Duplicate slug detection
|
||||||
curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
* Missing ownership records
|
||||||
|
* Invalid registry entries
|
||||||
|
* Invalid target types
|
||||||
|
* Orphaned slug references
|
||||||
|
|
||||||
|
Namespace conflicts now abort upgrades and restores before corruption can occur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reservation-Based Slug Allocation
|
||||||
|
|
||||||
|
BZOD now reserves slugs before content creation.
|
||||||
|
|
||||||
|
Creation workflow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quota Check
|
||||||
|
↓
|
||||||
|
Reserve Global Slug
|
||||||
|
↓
|
||||||
|
Create Content
|
||||||
|
↓
|
||||||
|
Activate Slug
|
||||||
|
↓
|
||||||
|
Increment Quota
|
||||||
|
↓
|
||||||
|
Audit Log
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
* Prevents race conditions
|
||||||
|
* Prevents duplicate creation under concurrency
|
||||||
|
* Enables safer rollback handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stale Reservation Recovery
|
||||||
|
|
||||||
|
Added automatic cleanup of abandoned slug reservations.
|
||||||
|
|
||||||
|
Scenarios covered:
|
||||||
|
|
||||||
|
* Server crash during creation
|
||||||
|
* Interrupted writes
|
||||||
|
* Failed transactions
|
||||||
|
|
||||||
|
BZOD now automatically recovers stale reservations during startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard Parity
|
||||||
|
|
||||||
|
Administrator and Standard User dashboards now provide equivalent functionality where appropriate.
|
||||||
|
|
||||||
|
Added parity validation for:
|
||||||
|
|
||||||
|
* URL management
|
||||||
|
* Landing page management
|
||||||
|
* Analytics
|
||||||
|
* QR code previews
|
||||||
|
* Export functionality
|
||||||
|
|
||||||
|
Differences remain only for administrator-specific operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unified Analytics Templates
|
||||||
|
|
||||||
|
Removed duplicated analytics templates.
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
* Consistent rendering
|
||||||
|
* Reduced maintenance burden
|
||||||
|
* Improved reliability
|
||||||
|
|
||||||
|
Administrator and user analytics now share the same rendering logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## QR Code Improvements
|
||||||
|
|
||||||
|
QR functionality was substantially improved.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* Inline QR previews
|
||||||
|
* PNG downloads
|
||||||
|
* SVG downloads
|
||||||
|
* Shared QR rendering component
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Landing page QR generation
|
||||||
|
* Multi-user QR ownership handling
|
||||||
|
* QR routing consistency
|
||||||
|
* Content-type validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canonical Landing Page Routing
|
||||||
|
|
||||||
|
Landing page slugs now redirect permanently to canonical page URLs.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/landing-page
|
||||||
|
```
|
||||||
|
|
||||||
|
redirects to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/p/landing-page
|
||||||
|
```
|
||||||
|
|
||||||
|
using:
|
||||||
|
|
||||||
|
```http
|
||||||
|
301 Moved Permanently
|
||||||
|
```
|
||||||
|
|
||||||
|
This improves consistency and SEO behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ownership Isolation Hardening
|
||||||
|
|
||||||
|
Additional protections ensure:
|
||||||
|
|
||||||
|
* Users cannot access another user's analytics
|
||||||
|
* Users cannot export another user's data
|
||||||
|
* Users cannot manage another user's resources
|
||||||
|
|
||||||
|
New ownership validation tests were added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup & Restore Improvements
|
||||||
|
|
||||||
|
Restore operations now validate namespace integrity before importing data.
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
* No silent slug collisions
|
||||||
|
* No partial restores
|
||||||
|
* No hidden ownership conflicts
|
||||||
|
|
||||||
|
Restore operations fail safely when conflicts are detected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade Validation Enhancements
|
||||||
|
|
||||||
|
Upgrade workflows now verify:
|
||||||
|
|
||||||
|
* Global namespace consistency
|
||||||
|
* Duplicate slug conflicts
|
||||||
|
* Registry integrity
|
||||||
|
* Tenant ownership correctness
|
||||||
|
|
||||||
|
Unsafe upgrades are blocked automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health & Diagnostics
|
||||||
|
|
||||||
|
The system health subsystem now validates:
|
||||||
|
|
||||||
|
* Global slug registry integrity
|
||||||
|
* Namespace conflicts
|
||||||
|
* Ownership consistency
|
||||||
|
* Stale reservations
|
||||||
|
|
||||||
|
This improves operational visibility and troubleshooting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing & Validation
|
||||||
|
|
||||||
|
BZOD v0.5.1 passed:
|
||||||
|
|
||||||
|
* Formatting validation (`cargo fmt --check`)
|
||||||
|
* Static analysis (`cargo clippy --all-targets -- -D warnings`)
|
||||||
|
* Full automated test suite
|
||||||
|
* Namespace integrity tests
|
||||||
|
* Ownership isolation tests
|
||||||
|
* QR endpoint tests
|
||||||
|
* Dashboard parity tests
|
||||||
|
* Upgrade validation tests
|
||||||
|
* Backup & restore tests
|
||||||
|
* Disaster recovery tests
|
||||||
|
* Security tests
|
||||||
|
* Concurrency tests
|
||||||
|
|
||||||
|
All automated tests pass successfully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Upgrade Notes
|
||||||
|
|
||||||
|
Administrators upgrading from v0.5.0 should review:
|
||||||
|
|
||||||
|
* UPGRADE.md
|
||||||
|
* MULTI_USER.md
|
||||||
|
* BACKUP_RESTORE.md
|
||||||
|
* DATABASES.md
|
||||||
|
* TESTING.md
|
||||||
|
|
||||||
|
BZOD will automatically validate namespace integrity before completing upgrades.
|
||||||
|
|
||||||
|
Duplicate slugs that previously existed across users or resource types must be resolved before migration can proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Breaking Changes
|
||||||
|
|
||||||
|
## Global Namespace Enforcement
|
||||||
|
|
||||||
|
Slugs are now globally unique across the entire platform.
|
||||||
|
|
||||||
|
Configurations that previously relied on duplicate slugs across users or resource types will be rejected during upgrade.
|
||||||
|
|
||||||
|
This behavior is intentional and protects routing integrity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
BZOD v0.5.1 is an integrity-focused release that significantly strengthens:
|
||||||
|
|
||||||
|
* Namespace safety
|
||||||
|
* Multi-tenant isolation
|
||||||
|
* Dashboard consistency
|
||||||
|
* QR reliability
|
||||||
|
* Restore safety
|
||||||
|
* Upgrade safety
|
||||||
|
* Operational diagnostics
|
||||||
|
|
||||||
|
The result is a more predictable, recoverable, and production-ready platform.
|
||||||
+362
-533
File diff suppressed because it is too large.
Load diff
+495
-173
@@ -1,25 +1,24 @@
|
|||||||
# Upgrade Guide
|
# Upgrade Guide
|
||||||
|
|
||||||
Version: v0.5.0
|
Version: v0.5.1
|
||||||
|
|
||||||
This document describes the upgrade process from previous BZOD releases to BZOD v0.5.0.
|
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
|
|
||||||
BZOD v0.5.0 introduces the largest architectural change in project history:
|
BZOD v0.5.1 is a platform hardening release focused on:
|
||||||
|
|
||||||
* Multi-user architecture
|
* Global namespace integrity
|
||||||
* Tenant isolation
|
* Multi-tenant safety
|
||||||
* Global slug namespace
|
* Dashboard parity
|
||||||
* Centralized authentication
|
* QR reliability
|
||||||
* User quotas
|
* Upgrade validation
|
||||||
* User-specific analytics
|
* Restore collision protection
|
||||||
* Administrative user management
|
* Ownership isolation
|
||||||
* Backup and restore framework
|
|
||||||
|
|
||||||
Existing v0.4.x deployments can be upgraded without data loss.
|
While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the operational and data integrity guarantees required for production deployments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,80 +27,175 @@ Existing v0.4.x deployments can be upgraded without data loss.
|
|||||||
Supported:
|
Supported:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
v0.4.0 → v0.5.0
|
v0.5.0 → v0.5.1
|
||||||
v0.4.x → v0.5.0
|
v0.4.x → v0.5.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```text
|
||||||
|
v0.4.x → v0.5.0 → v0.5.1
|
||||||
```
|
```
|
||||||
|
|
||||||
Unsupported:
|
Unsupported:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
v0.3.x → v0.5.0
|
v0.3.x → v0.5.1
|
||||||
```
|
```
|
||||||
|
|
||||||
Older installations should first upgrade to v0.4.x.
|
Older installations should first upgrade to v0.4.x.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Major Changes in v0.5.1
|
||||||
|
|
||||||
|
## Global Namespace Enforcement
|
||||||
|
|
||||||
|
BZOD now enforces a single platform-wide slug namespace.
|
||||||
|
|
||||||
|
The following resources can no longer share the same slug:
|
||||||
|
|
||||||
|
* Administrator URLs
|
||||||
|
* Administrator Landing Pages
|
||||||
|
* User URLs
|
||||||
|
* User Landing Pages
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Admin URL:
|
||||||
|
hello
|
||||||
|
|
||||||
|
User URL:
|
||||||
|
hello
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Upgrade aborted.
|
||||||
|
Namespace conflict detected.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Slug Registry
|
||||||
|
|
||||||
|
BZOD now treats the slug registry as the authoritative source of truth.
|
||||||
|
|
||||||
|
All slugs are registered in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
system.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Table:
|
||||||
|
|
||||||
|
```text
|
||||||
|
global_slugs
|
||||||
|
```
|
||||||
|
|
||||||
|
The registry tracks:
|
||||||
|
|
||||||
|
```text
|
||||||
|
slug
|
||||||
|
owner_user_id
|
||||||
|
target_type
|
||||||
|
target_id
|
||||||
|
status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reservation-Based Slug Allocation
|
||||||
|
|
||||||
|
Slug creation now follows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quota Validation
|
||||||
|
↓
|
||||||
|
Reserve Global Slug
|
||||||
|
↓
|
||||||
|
Create Resource
|
||||||
|
↓
|
||||||
|
Activate Slug
|
||||||
|
↓
|
||||||
|
Update Quotas
|
||||||
|
↓
|
||||||
|
Audit Log
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
* Prevents race conditions
|
||||||
|
* Prevents duplicate allocations
|
||||||
|
* Improves rollback safety
|
||||||
|
* Improves multi-user integrity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stale Reservation Recovery
|
||||||
|
|
||||||
|
BZOD automatically cleans abandoned reservations created by:
|
||||||
|
|
||||||
|
* Server crashes
|
||||||
|
* Interrupted requests
|
||||||
|
* Failed transactions
|
||||||
|
|
||||||
|
Stale reservations are validated and cleaned during startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Breaking Changes
|
# Breaking Changes
|
||||||
|
|
||||||
## Database Layout
|
## Global Slug Uniqueness
|
||||||
|
|
||||||
### v0.4.x
|
Deployments containing duplicate slugs will not upgrade.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data/
|
User 1:
|
||||||
├── admin.db
|
!nx9-dns-server
|
||||||
├── content.db
|
|
||||||
└── analytics.db
|
User 3:
|
||||||
|
!nx9-dns-server
|
||||||
```
|
```
|
||||||
|
|
||||||
### v0.5.0
|
Result:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data/
|
Upgrade aborted.
|
||||||
├── users.db
|
|
||||||
├── system.db
|
Database upgrade aborted due to slug conflicts.
|
||||||
└── users/
|
|
||||||
└── 1/
|
|
||||||
├── content.db
|
|
||||||
└── analytics.db
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Conflicts must be resolved before migration can continue.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication
|
## Restore Collision Protection
|
||||||
|
|
||||||
Authentication is now centralized.
|
Restore operations now validate namespace integrity.
|
||||||
|
|
||||||
Old:
|
Example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
admin.db
|
Existing slug:
|
||||||
|
company
|
||||||
|
|
||||||
|
Backup slug:
|
||||||
|
company
|
||||||
```
|
```
|
||||||
|
|
||||||
New:
|
Result:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
users.db
|
Restore aborted.
|
||||||
|
Slug conflict detected.
|
||||||
```
|
```
|
||||||
|
|
||||||
Sessions are managed globally.
|
No partial restore occurs.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Global Slug Namespace
|
|
||||||
|
|
||||||
Slugs are now unique platform-wide.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/example
|
|
||||||
/company
|
|
||||||
/docs
|
|
||||||
```
|
|
||||||
|
|
||||||
cannot exist twice.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -109,22 +203,23 @@ cannot exist twice.
|
|||||||
|
|
||||||
Before upgrading:
|
Before upgrading:
|
||||||
|
|
||||||
* Verify current version
|
|
||||||
* Stop active traffic
|
|
||||||
* Create backup
|
* Create backup
|
||||||
* Verify backup integrity
|
* Verify backup integrity
|
||||||
|
* Stop active traffic
|
||||||
|
* Run diagnostics
|
||||||
|
* Resolve namespace conflicts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 1: Create Backup
|
# Step 1: Create Backup
|
||||||
|
|
||||||
CLI:
|
Full backup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bzod backup
|
bzod backup
|
||||||
```
|
```
|
||||||
|
|
||||||
or manually archive:
|
Manual backup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar czf bzod-backup.tar.gz data/
|
tar czf bzod-backup.tar.gz data/
|
||||||
@@ -132,9 +227,18 @@ tar czf bzod-backup.tar.gz data/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2: Verify Backup
|
# Step 2: Verify Backup
|
||||||
|
|
||||||
Confirm archive contains:
|
Verify archive contents:
|
||||||
|
|
||||||
|
```text
|
||||||
|
users.db
|
||||||
|
system.db
|
||||||
|
|
||||||
|
users/
|
||||||
|
```
|
||||||
|
|
||||||
|
If upgrading from legacy versions:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
admin.db
|
admin.db
|
||||||
@@ -142,9 +246,35 @@ content.db
|
|||||||
analytics.db
|
analytics.db
|
||||||
```
|
```
|
||||||
|
|
||||||
|
should also be present.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 3: Stop Service
|
# Step 3: Run Diagnostics
|
||||||
|
|
||||||
|
Execute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bzod doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Overall Status: HEALTHY
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```text
|
||||||
|
No namespace conflicts detected
|
||||||
|
No ownership violations detected
|
||||||
|
No registry corruption detected
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Step 4: Stop Service
|
||||||
|
|
||||||
Systemd:
|
Systemd:
|
||||||
|
|
||||||
@@ -162,15 +292,15 @@ docker compose down
|
|||||||
|
|
||||||
# Upgrade Procedure
|
# Upgrade Procedure
|
||||||
|
|
||||||
## Replace Binary
|
## Install New Version
|
||||||
|
|
||||||
Install new release:
|
Build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
or download release binary.
|
Or install official release binary.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,93 +310,136 @@ or download release binary.
|
|||||||
bzod serve
|
bzod serve
|
||||||
```
|
```
|
||||||
|
|
||||||
On first startup BZOD automatically:
|
or:
|
||||||
|
|
||||||
1. Detects legacy databases.
|
```bash
|
||||||
2. Creates users.db.
|
docker compose up -d
|
||||||
3. Creates system.db.
|
|
||||||
4. Creates administrator tenant.
|
|
||||||
5. Moves content.db.
|
|
||||||
6. Moves analytics.db.
|
|
||||||
7. Creates global slug registry.
|
|
||||||
8. Runs migrations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Automatic Migration
|
|
||||||
|
|
||||||
Migration performs:
|
|
||||||
|
|
||||||
## Administrator Creation
|
|
||||||
|
|
||||||
Legacy administrator becomes:
|
|
||||||
|
|
||||||
```text
|
|
||||||
User ID: 1
|
|
||||||
Type: admin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Content Migration
|
# Automatic Upgrade Actions
|
||||||
|
|
||||||
All URLs migrate into:
|
During startup BZOD automatically performs:
|
||||||
|
|
||||||
|
1. Database migration checks
|
||||||
|
2. Namespace integrity validation
|
||||||
|
3. Registry validation
|
||||||
|
4. Stale reservation cleanup
|
||||||
|
5. Global slug verification
|
||||||
|
6. Schema migration execution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Namespace Validation
|
||||||
|
|
||||||
|
BZOD scans:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
users/1/content.db
|
legacy databases
|
||||||
|
administrator databases
|
||||||
|
tenant databases
|
||||||
|
```
|
||||||
|
|
||||||
|
for duplicate slugs.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Owner 1:
|
||||||
|
hello
|
||||||
|
|
||||||
|
Owner 3:
|
||||||
|
hello
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Namespace conflict detected.
|
||||||
|
Upgrade aborted.
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Analytics Migration
|
# Registry Validation
|
||||||
|
|
||||||
All analytics migrate into:
|
BZOD validates:
|
||||||
|
|
||||||
|
* Duplicate slug entries
|
||||||
|
* Missing owners
|
||||||
|
* Missing targets
|
||||||
|
* Invalid target types
|
||||||
|
* Invalid status values
|
||||||
|
|
||||||
|
Allowed target types:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
users/1/analytics.db
|
url
|
||||||
|
page
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Allowed statuses:
|
||||||
|
|
||||||
## Global Slug Registration
|
|
||||||
|
|
||||||
All existing slugs are inserted into:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
system.db.global_slugs
|
reserving
|
||||||
|
active
|
||||||
|
disabled
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Post-Upgrade Validation
|
# Post-Upgrade Validation
|
||||||
|
|
||||||
## Login
|
Run:
|
||||||
|
|
||||||
Verify:
|
```bash
|
||||||
|
bzod doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Admin login succeeds
|
Namespace Integrity: PASS
|
||||||
|
Registry Integrity: PASS
|
||||||
|
Ownership Integrity: PASS
|
||||||
|
Database Integrity: PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## URLs
|
# Login Validation
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Short URLs redirect
|
Administrator login succeeds
|
||||||
|
User login succeeds
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
---
|
||||||
|
|
||||||
|
# URL Validation
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://example.com/abc123
|
https://example.com/abc123
|
||||||
```
|
```
|
||||||
|
|
||||||
|
redirects correctly.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```http
|
||||||
|
302 Found
|
||||||
|
```
|
||||||
|
|
||||||
|
or configured redirect behavior.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Landing Pages
|
# Landing Page Validation
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
@@ -274,90 +447,166 @@ Verify:
|
|||||||
https://example.com/p/demo
|
https://example.com/p/demo
|
||||||
```
|
```
|
||||||
|
|
||||||
renders correctly.
|
renders successfully.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Analytics
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
* Visits visible
|
|
||||||
* Reports load
|
|
||||||
* Charts render
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Management
|
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Admin → Users
|
https://example.com/demo
|
||||||
```
|
```
|
||||||
|
|
||||||
loads correctly.
|
redirects permanently:
|
||||||
|
|
||||||
|
```http
|
||||||
|
301 Moved Permanently
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/p/demo
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Upgrade Validation Tests
|
# QR Validation
|
||||||
|
|
||||||
BZOD v0.5.0 includes automated migration tests.
|
Verify:
|
||||||
|
|
||||||
Validated:
|
```text
|
||||||
|
/api/qr/demo.png
|
||||||
|
/api/qr/demo.svg
|
||||||
|
```
|
||||||
|
|
||||||
* Legacy admin migration
|
Expected:
|
||||||
* Legacy content migration
|
|
||||||
* Legacy analytics migration
|
|
||||||
* Slug registration
|
|
||||||
* Redirect preservation
|
|
||||||
* Analytics preservation
|
|
||||||
|
|
||||||
Test suite:
|
```http
|
||||||
|
200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Content types:
|
||||||
|
|
||||||
|
```text
|
||||||
|
image/png
|
||||||
|
image/svg+xml
|
||||||
|
```
|
||||||
|
|
||||||
|
Disabled resources:
|
||||||
|
|
||||||
|
```http
|
||||||
|
410 Gone
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing resources:
|
||||||
|
|
||||||
|
```http
|
||||||
|
404 Not Found
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dashboard Validation
|
||||||
|
|
||||||
|
Verify Administrator Dashboards:
|
||||||
|
|
||||||
|
* URLs
|
||||||
|
* Landing Pages
|
||||||
|
* Analytics
|
||||||
|
* QR Preview
|
||||||
|
* PNG Download
|
||||||
|
* SVG Download
|
||||||
|
|
||||||
|
Verify Standard User Dashboards:
|
||||||
|
|
||||||
|
* URLs
|
||||||
|
* Landing Pages
|
||||||
|
* Analytics
|
||||||
|
* QR Preview
|
||||||
|
* PNG Download
|
||||||
|
* SVG Download
|
||||||
|
|
||||||
|
Both should provide equivalent functionality except for administrator-only operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ownership Isolation Validation
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```text
|
||||||
|
User A
|
||||||
|
```
|
||||||
|
|
||||||
|
cannot access:
|
||||||
|
|
||||||
|
```text
|
||||||
|
User B Analytics
|
||||||
|
User B URLs
|
||||||
|
User B Landing Pages
|
||||||
|
User B Exports
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```http
|
||||||
|
403 Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backup & Restore Validation
|
||||||
|
|
||||||
|
Create backup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --test upgrade_validation_tests
|
bzod backup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Restore backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bzod restore backup.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
* No namespace conflicts
|
||||||
|
* No ownership conflicts
|
||||||
|
* No partial restores
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Rollback Procedure
|
# Rollback Procedure
|
||||||
|
|
||||||
If upgrade validation fails:
|
If upgrade validation fails:
|
||||||
|
|
||||||
## Stop Server
|
Stop service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl stop bzod
|
sudo systemctl stop bzod
|
||||||
```
|
```
|
||||||
|
|
||||||
or
|
or:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Restore backup:
|
||||||
|
|
||||||
## Restore Backup
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bzod restore backup.zip
|
bzod restore backup.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
or restore archived data directory.
|
or restore archived data directory.
|
||||||
|
|
||||||
---
|
Reinstall previous release.
|
||||||
|
|
||||||
## Reinstall Previous Release
|
|
||||||
|
|
||||||
Deploy previous v0.4.x binary.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Docker Upgrade
|
# Docker Upgrade
|
||||||
|
|
||||||
Pull new image:
|
Pull image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
docker compose pull
|
||||||
@@ -369,13 +618,19 @@ Restart:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Monitor logs:
|
Monitor:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify migrations complete successfully.
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Namespace validation passed
|
||||||
|
Registry validation passed
|
||||||
|
Server started successfully
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -399,75 +654,142 @@ Verify:
|
|||||||
sudo systemctl status bzod
|
sudo systemctl status bzod
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active (running)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Automated Upgrade Validation
|
||||||
|
|
||||||
|
Execute:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo test --all-targets -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
Particularly validate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
upgrade_validation_tests
|
||||||
|
backup_restore_tests
|
||||||
|
slug_registry_tests
|
||||||
|
ownership_tests
|
||||||
|
analytics_parity_tests
|
||||||
|
transaction_tests
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Recommended Upgrade Workflow
|
# Recommended Upgrade Workflow
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. Create backup
|
1. Create Backup
|
||||||
2. Stop service
|
2. Verify Backup
|
||||||
3. Install v0.5.0
|
3. Run bzod doctor
|
||||||
4. Start service
|
4. Resolve Namespace Conflicts
|
||||||
5. Run migrations
|
5. Stop Service
|
||||||
6. Validate login
|
6. Install v0.5.1
|
||||||
7. Validate URLs
|
7. Start Service
|
||||||
8. Validate analytics
|
8. Validate Registry
|
||||||
9. Validate admin dashboard
|
9. Validate URLs
|
||||||
10. Return to production
|
10. Validate Landing Pages
|
||||||
|
11. Validate QR Endpoints
|
||||||
|
12. Validate Dashboards
|
||||||
|
13. Validate Ownership Isolation
|
||||||
|
14. Return To Production
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
## Login Fails
|
## Upgrade Aborted Due To Slug Conflicts
|
||||||
|
|
||||||
Check:
|
Example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
users.db
|
Slug '!nx9-dns-server'
|
||||||
|
is defined in multiple content databases
|
||||||
|
by owners [1,3]
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify administrator account exists.
|
Cause:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Duplicate slug detected.
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Rename or remove conflicting resources.
|
||||||
|
Restart upgrade.
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## URLs Missing
|
## QR Codes Return 404
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
users/1/content.db
|
global_slugs
|
||||||
```
|
```
|
||||||
|
|
||||||
contains migrated records.
|
contains the slug.
|
||||||
|
|
||||||
|
Verify slug status:
|
||||||
|
|
||||||
|
```text
|
||||||
|
active
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Analytics Missing
|
## Landing Page Redirect Fails
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
users/1/analytics.db
|
target_type = page
|
||||||
```
|
```
|
||||||
|
|
||||||
contains visit data.
|
in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
global_slugs
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Slug Resolution Fails
|
## Ownership Errors
|
||||||
|
|
||||||
Verify:
|
Run:
|
||||||
|
|
||||||
```sql
|
```bash
|
||||||
SELECT * FROM global_slugs;
|
bzod doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
returns expected entries.
|
Verify ownership integrity passes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Upgrade Status
|
# Upgrade Status
|
||||||
|
|
||||||
BZOD v0.5.0 upgrade path has been validated through automated migration and integration testing and is considered production-ready for upgrades from v0.4.x deployments.
|
BZOD v0.5.1 upgrade path has been validated through:
|
||||||
|
|
||||||
|
* Migration Tests
|
||||||
|
* Upgrade Validation Tests
|
||||||
|
* Namespace Integrity Tests
|
||||||
|
* Ownership Isolation Tests
|
||||||
|
* Backup & Restore Tests
|
||||||
|
* Dashboard Parity Tests
|
||||||
|
* QR Endpoint Tests
|
||||||
|
* Routing Tests
|
||||||
|
|
||||||
|
The v0.5.1 upgrade path is considered production-ready.
|
||||||
+67
-3
@@ -22,11 +22,23 @@ pub async fn run(
|
|||||||
println!("Data directory: {:?}", config.data_dir);
|
println!("Data directory: {:?}", config.data_dir);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let databases = ["admin", "content", "analytics", "system"];
|
|
||||||
let mut all_healthy = true;
|
let mut all_healthy = true;
|
||||||
|
|
||||||
for db_name in &databases {
|
// Define target databases in the new layout
|
||||||
let db_path = config.data_dir.join(format!("{}.db", db_name));
|
let admin_dir = config.data_dir.join("admin");
|
||||||
|
let dbs = vec![
|
||||||
|
("admin", admin_dir.join("admin.db")),
|
||||||
|
("system", admin_dir.join("system.db")),
|
||||||
|
("users", admin_dir.join("users.db")),
|
||||||
|
("legacy content", config.data_dir.join("content.db")),
|
||||||
|
("legacy analytics", config.data_dir.join("analytics.db")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (db_name, db_path) in dbs {
|
||||||
|
// Skip legacy databases if they don't exist
|
||||||
|
if db_name.starts_with("legacy") && !db_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if !db_path.exists() {
|
if !db_path.exists() {
|
||||||
println!("Database: {}", db_name);
|
println!("Database: {}", db_name);
|
||||||
@@ -75,6 +87,58 @@ pub async fn run(
|
|||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global Slug Registry Integrity Check
|
||||||
|
println!("Global Slug Registry Integrity Check");
|
||||||
|
println!("====================================");
|
||||||
|
let system_db_path = admin_dir.join("system.db");
|
||||||
|
let users_db_path = admin_dir.join("users.db");
|
||||||
|
|
||||||
|
if system_db_path.exists() && users_db_path.exists() {
|
||||||
|
match (
|
||||||
|
Connection::open(&system_db_path),
|
||||||
|
Connection::open(&users_db_path),
|
||||||
|
) {
|
||||||
|
(Ok(sys_conn), Ok(usr_conn)) => {
|
||||||
|
match crate::db::users::verify_global_slug_registry_integrity(
|
||||||
|
&sys_conn,
|
||||||
|
&usr_conn,
|
||||||
|
&config.data_dir,
|
||||||
|
) {
|
||||||
|
Ok((errors, warnings)) => {
|
||||||
|
if errors.is_empty() && warnings.is_empty() {
|
||||||
|
println!(" Status: HEALTHY (no issues found)");
|
||||||
|
} else {
|
||||||
|
if !errors.is_empty() {
|
||||||
|
println!(" Errors (Action Required):");
|
||||||
|
for err in &errors {
|
||||||
|
println!(" - {}", err);
|
||||||
|
}
|
||||||
|
all_healthy = false;
|
||||||
|
}
|
||||||
|
if !warnings.is_empty() {
|
||||||
|
println!(" Warnings (Attention Needed):");
|
||||||
|
for warn in &warnings {
|
||||||
|
println!(" - {}", warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!(" Status: ERROR running integrity check: {}", e);
|
||||||
|
all_healthy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!(" Status: ERROR opening system.db or users.db for integrity check");
|
||||||
|
all_healthy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!(" Status: SKIPPED (system.db/users.db not found)");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
println!("--------------------");
|
println!("--------------------");
|
||||||
if all_healthy {
|
if all_healthy {
|
||||||
println!("Overall status: HEALTHY");
|
println!("Overall status: HEALTHY");
|
||||||
|
|||||||
+83
-22
@@ -15,34 +15,95 @@ pub fn perform_restore(
|
|||||||
let tar_gz = GzDecoder::new(f);
|
let tar_gz = GzDecoder::new(f);
|
||||||
let mut archive = Archive::new(tar_gz);
|
let mut archive = Archive::new(tar_gz);
|
||||||
|
|
||||||
// 2. Validate that the archive contains the expected BZOD database files
|
// 2. Unpack to temporary directory first
|
||||||
let mut has_admin = false;
|
let temp_dir =
|
||||||
let mut has_content = false;
|
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
|
||||||
let mut has_analytics = false;
|
std::fs::create_dir_all(&temp_dir)?;
|
||||||
let mut has_system = false;
|
|
||||||
|
|
||||||
for entry_res in archive.entries()? {
|
if let Err(e) = archive.unpack(&temp_dir) {
|
||||||
let entry = entry_res?;
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
let path = entry.path()?;
|
return Err(e.into());
|
||||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
}
|
||||||
match file_name {
|
|
||||||
"admin.db" => has_admin = true,
|
// 3. Run validation on temp_dir
|
||||||
"content.db" => has_content = true,
|
let mut temp_config = Config::load();
|
||||||
"analytics.db" => has_analytics = true,
|
temp_config.data_dir = temp_dir.clone();
|
||||||
"system.db" => has_system = true,
|
|
||||||
_ => {}
|
// Namespace audit
|
||||||
|
match crate::db::users::audit_slug_namespace(&temp_config) {
|
||||||
|
Ok(report) => {
|
||||||
|
if !report.duplicates.is_empty() {
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(
|
||||||
|
format!("Slug conflicts detected in backup: {:?}", report.duplicates).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(format!("Failed to audit slug namespace in backup: {}", e).into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !has_admin || !has_content || !has_analytics || !has_system {
|
// Registry integrity check
|
||||||
return Err("Archive is missing one or more required database files (admin.db, content.db, analytics.db, system.db)".into());
|
let system_db_path = if temp_dir.join("admin/system.db").exists() {
|
||||||
|
temp_dir.join("admin/system.db")
|
||||||
|
} else {
|
||||||
|
temp_dir.join("system.db")
|
||||||
|
};
|
||||||
|
let users_db_path = if temp_dir.join("admin/users.db").exists() {
|
||||||
|
temp_dir.join("admin/users.db")
|
||||||
|
} else {
|
||||||
|
temp_dir.join("users.db")
|
||||||
|
};
|
||||||
|
|
||||||
|
if system_db_path.exists() && users_db_path.exists() {
|
||||||
|
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||||
|
let users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||||
|
match crate::db::users::verify_global_slug_registry_integrity(
|
||||||
|
&system_conn,
|
||||||
|
&users_conn,
|
||||||
|
&temp_dir,
|
||||||
|
) {
|
||||||
|
Ok((errors, _warnings)) => {
|
||||||
|
if !errors.is_empty() {
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(format!("Failed to verify registry integrity in backup: {}", e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Unpack archive to data_dir
|
// 4. If validation succeeds, copy temp_dir contents to data_dir
|
||||||
let f2 = File::open(file_path)?;
|
if data_dir.exists() {
|
||||||
let tar_gz2 = GzDecoder::new(f2);
|
let _ = std::fs::remove_dir_all(data_dir);
|
||||||
let mut archive2 = Archive::new(tar_gz2);
|
}
|
||||||
archive2.unpack(data_dir)?;
|
std::fs::create_dir_all(data_dir)?;
|
||||||
|
|
||||||
|
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
|
||||||
|
std::fs::create_dir_all(dst)?;
|
||||||
|
for entry in std::fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let ty = entry.file_type()?;
|
||||||
|
if ty.is_dir() {
|
||||||
|
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
|
||||||
|
} else {
|
||||||
|
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = copy_dir_all(&temp_dir, data_dir) {
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(format!("Failed to copy restored files: {}", e).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-94
@@ -1,7 +1,5 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
use chrono::Utc;
|
|
||||||
use rusqlite::OptionalExtension;
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tar::Archive;
|
use tar::Archive;
|
||||||
@@ -158,103 +156,18 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Register slugs in global_slugs
|
// 4. Register slugs in global_slugs using the shared helper
|
||||||
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
|
|
||||||
{
|
{
|
||||||
let mut system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
let tx = system_conn.transaction()?;
|
crate::db::users::register_restored_user_slugs(
|
||||||
|
&system_conn,
|
||||||
// Delete any existing global slugs owned by this user
|
target_user_id,
|
||||||
tx.execute(
|
&dest_dir.join("content.db"),
|
||||||
"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
|
// 5. Reconcile quotas for restored user
|
||||||
|
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
|
||||||
crate::db::users::reconcile_user_quotas(
|
crate::db::users::reconcile_user_quotas(
|
||||||
&db.users.lock().unwrap(),
|
&db.users.lock().unwrap(),
|
||||||
target_user_id,
|
target_user_id,
|
||||||
|
|||||||
+29
-7
@@ -33,7 +33,16 @@ pub async fn run(
|
|||||||
None => crate::utils::random::generate_token(3),
|
None => crate::utils::random::generate_token(3),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Persist URL
|
// 3. Register slug in system.db with status 'reserving' and check availability
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
if !crate::db::users::is_slug_available(&system_conn, &code)? {
|
||||||
|
return Err("Short code/slug already exists".into());
|
||||||
|
}
|
||||||
|
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Persist URL
|
||||||
let conn = db.content.lock().unwrap();
|
let conn = db.content.lock().unwrap();
|
||||||
let res = crate::db::content::create_url_extended(
|
let res = crate::db::content::create_url_extended(
|
||||||
&conn,
|
&conn,
|
||||||
@@ -48,7 +57,21 @@ pub async fn run(
|
|||||||
);
|
);
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
Ok(_) => {
|
Ok(url) => {
|
||||||
|
// Activate slug in system.db
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||||
|
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
// Increment quota for user ID 1
|
||||||
|
{
|
||||||
|
let users_conn = db.users.lock().unwrap();
|
||||||
|
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
|
||||||
|
}
|
||||||
|
|
||||||
let proto = if config.cookie_secure {
|
let proto = if config.cookie_secure {
|
||||||
"https"
|
"https"
|
||||||
} else {
|
} else {
|
||||||
@@ -63,11 +86,10 @@ pub async fn run(
|
|||||||
println!("{}/{}", base_url, code);
|
println!("{}/{}", base_url, code);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
Err(e) => {
|
||||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
let system_conn = db.system.lock().unwrap();
|
||||||
{
|
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||||
Err("Short code/slug already exists".into())
|
Err(e.into())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +68,26 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-migration safety net: audit slug namespace for duplicates / format errors
|
||||||
|
match crate::db::users::audit_slug_namespace(config) {
|
||||||
|
Ok(report) => {
|
||||||
|
if !report.duplicates.is_empty() {
|
||||||
|
tracing::error!(
|
||||||
|
"Namespace conflicts detected before database migration: {:?}",
|
||||||
|
report.duplicates
|
||||||
|
);
|
||||||
|
return Err(format!(
|
||||||
|
"Database upgrade aborted due to slug conflicts: {:?}",
|
||||||
|
report.duplicates
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to audit slug namespace before migration: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let admin_path = admin_dir.join("admin.db");
|
let admin_path = admin_dir.join("admin.db");
|
||||||
let system_path = admin_dir.join("system.db");
|
let system_path = admin_dir.join("system.db");
|
||||||
let users_db_path = admin_dir.join("users.db");
|
let users_db_path = admin_dir.join("users.db");
|
||||||
@@ -317,6 +337,44 @@ impl Db {
|
|||||||
|
|
||||||
let _ = db.reconcile_global_slugs(config);
|
let _ = db.reconcile_global_slugs(config);
|
||||||
|
|
||||||
|
// Post-init: Clean up stale reservations
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
|
||||||
|
Ok(count) => {
|
||||||
|
if count > 0 {
|
||||||
|
tracing::info!("Cleaned up {} stale reserving slugs", count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to clean up stale reservations: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-init: Verify global registry integrity
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
let users_conn = db.users.lock().unwrap();
|
||||||
|
match crate::db::users::verify_global_slug_registry_integrity(
|
||||||
|
&system_conn,
|
||||||
|
&users_conn,
|
||||||
|
&config.data_dir,
|
||||||
|
) {
|
||||||
|
Ok((errors, warnings)) => {
|
||||||
|
for err in errors {
|
||||||
|
tracing::error!("Global registry integrity error: {}", err);
|
||||||
|
}
|
||||||
|
for warn in warnings {
|
||||||
|
tracing::warn!("Global registry integrity warning: {}", warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to verify global registry integrity: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+469
-2
@@ -303,6 +303,19 @@ pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Opti
|
|||||||
.optional()
|
.optional()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn check_quota_limit(conn: &Connection, user_id: i64, field: &str) -> rusqlite::Result<bool> {
|
||||||
|
if let Some(quotas) = get_user_quotas(conn, user_id)? {
|
||||||
|
match field {
|
||||||
|
"urls" => Ok(quotas.current_urls < quotas.max_urls),
|
||||||
|
"landings" => Ok(quotas.current_landings < quotas.max_landings),
|
||||||
|
"api_tokens" => Ok(quotas.current_api_tokens < quotas.max_api_tokens),
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_user_quotas(
|
pub fn update_user_quotas(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
@@ -458,12 +471,13 @@ pub fn register_global_slug(
|
|||||||
owner_user_id: i64,
|
owner_user_id: i64,
|
||||||
target_type: &str,
|
target_type: &str,
|
||||||
target_id: &str,
|
target_id: &str,
|
||||||
|
status: &str,
|
||||||
) -> rusqlite::Result<()> {
|
) -> rusqlite::Result<()> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
system_conn.execute(
|
system_conn.execute(
|
||||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
"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);",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||||
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"],
|
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, status],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Insert history
|
// Insert history
|
||||||
@@ -501,7 +515,7 @@ pub fn soft_delete_global_slug(
|
|||||||
) -> rusqlite::Result<()> {
|
) -> rusqlite::Result<()> {
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
system_conn.execute(
|
system_conn.execute(
|
||||||
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;",
|
"UPDATE global_slugs SET status = 'disabled', deleted_at = ?1 WHERE slug = ?2;",
|
||||||
rusqlite::params![now, slug],
|
rusqlite::params![now, slug],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -515,6 +529,459 @@ pub fn soft_delete_global_slug(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct SlugAuditReport {
|
||||||
|
pub duplicates: Vec<String>,
|
||||||
|
pub invalid_entries: Vec<String>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn audit_slug_namespace(
|
||||||
|
config: &crate::config::Config,
|
||||||
|
) -> Result<SlugAuditReport, Box<dyn std::error::Error>> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut duplicates = Vec::new();
|
||||||
|
let mut invalid_entries = Vec::new();
|
||||||
|
let warnings = Vec::new();
|
||||||
|
|
||||||
|
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
|
||||||
|
|
||||||
|
// 1. Scan legacy content.db if it exists
|
||||||
|
let legacy_content_path = config.data_dir.join("content.db");
|
||||||
|
if legacy_content_path.exists() {
|
||||||
|
if let Ok(conn) = Connection::open(&legacy_content_path) {
|
||||||
|
// URLs
|
||||||
|
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
|
||||||
|
if let Ok(mut rows) = stmt.query([]) {
|
||||||
|
while let Some(row) = rows.next().unwrap_or(None) {
|
||||||
|
if let Ok(code) = row.get::<_, String>(0) {
|
||||||
|
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Landing Pages
|
||||||
|
if let Ok(mut stmt) = conn.prepare("SELECT code FROM landing_pages;") {
|
||||||
|
if let Ok(mut rows) = stmt.query([]) {
|
||||||
|
while let Some(row) = rows.next().unwrap_or(None) {
|
||||||
|
if let Ok(code) = row.get::<_, String>(0) {
|
||||||
|
slug_owners.entry(code).or_default().push(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
|
||||||
|
let users_dir = config.data_dir.join("users");
|
||||||
|
if users_dir.exists() {
|
||||||
|
for entry in std::fs::read_dir(users_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
if let Ok(user_id) = name_str.parse::<i64>() {
|
||||||
|
let content_db_path = path.join("content.db");
|
||||||
|
if content_db_path.exists() {
|
||||||
|
if let Ok(conn) = Connection::open(&content_db_path) {
|
||||||
|
// URLs
|
||||||
|
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
|
||||||
|
if let Ok(mut rows) = stmt.query([]) {
|
||||||
|
while let Some(row) = rows.next().unwrap_or(None) {
|
||||||
|
if let Ok(code) = row.get::<_, String>(0) {
|
||||||
|
slug_owners.entry(code).or_default().push(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Landing pages
|
||||||
|
if let Ok(mut stmt) =
|
||||||
|
conn.prepare("SELECT code FROM landing_pages;")
|
||||||
|
{
|
||||||
|
if let Ok(mut rows) = stmt.query([]) {
|
||||||
|
while let Some(row) = rows.next().unwrap_or(None) {
|
||||||
|
if let Ok(code) = row.get::<_, String>(0) {
|
||||||
|
slug_owners.entry(code).or_default().push(user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Populate report
|
||||||
|
for (slug, owners) in slug_owners {
|
||||||
|
if owners.len() > 1 {
|
||||||
|
duplicates.push(format!(
|
||||||
|
"Slug '{}' is defined in multiple content databases by owners {:?}",
|
||||||
|
slug, owners
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Validate slug format
|
||||||
|
let valid_url = crate::utils::validation::validate_redirect_code(&slug);
|
||||||
|
let valid_page = crate::utils::validation::validate_page_code(&slug);
|
||||||
|
if !valid_url && !valid_page {
|
||||||
|
invalid_entries.push(format!("Slug '{}' is format-invalid", slug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SlugAuditReport {
|
||||||
|
duplicates,
|
||||||
|
invalid_entries,
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cleanup_stale_reservations(
|
||||||
|
system_conn: &Connection,
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
let mut cleaned_count = 0;
|
||||||
|
|
||||||
|
let mut stmt = system_conn.prepare(
|
||||||
|
"SELECT slug, owner_user_id, target_type, created_at FROM global_slugs WHERE status = 'reserving';"
|
||||||
|
)?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
let mut stale_slugs = Vec::new();
|
||||||
|
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let slug: String = row.get(0)?;
|
||||||
|
let owner_user_id: i64 = row.get(1)?;
|
||||||
|
let target_type: String = row.get(2)?;
|
||||||
|
let created_at_str: String = row.get(3)?;
|
||||||
|
|
||||||
|
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||||
|
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||||
|
if age > chrono::Duration::minutes(15) {
|
||||||
|
// Check if target record exists by looking up code = slug in owner's content.db
|
||||||
|
let content_db_path = if owner_user_id == 1 {
|
||||||
|
let p1 = data_dir.join("users").join("1").join("content.db");
|
||||||
|
if p1.exists() {
|
||||||
|
p1
|
||||||
|
} else {
|
||||||
|
data_dir.join("content.db")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data_dir
|
||||||
|
.join("users")
|
||||||
|
.join(owner_user_id.to_string())
|
||||||
|
.join("content.db")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut target_exists = false;
|
||||||
|
if content_db_path.exists() {
|
||||||
|
if let Ok(conn) = Connection::open(&content_db_path) {
|
||||||
|
if target_type == "url" {
|
||||||
|
target_exists = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM urls WHERE code = ?1);",
|
||||||
|
[&slug],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
} else if target_type == "page" {
|
||||||
|
target_exists = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE code = ?1);",
|
||||||
|
[&slug],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target_exists {
|
||||||
|
stale_slugs.push((slug, owner_user_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(rows);
|
||||||
|
drop(stmt);
|
||||||
|
|
||||||
|
for (slug, owner_user_id) in stale_slugs {
|
||||||
|
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug])?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
system_conn.execute(
|
||||||
|
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
|
||||||
|
VALUES (?1, ?2, NULL, 'released', ?3);",
|
||||||
|
rusqlite::params![slug, owner_user_id, now],
|
||||||
|
)?;
|
||||||
|
cleaned_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cleaned_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_global_slug_registry_integrity(
|
||||||
|
system_conn: &Connection,
|
||||||
|
users_conn: &Connection,
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
// 1. Check duplicate slugs
|
||||||
|
let total_count: i64 =
|
||||||
|
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||||
|
let distinct_count: i64 =
|
||||||
|
system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})?;
|
||||||
|
if total_count != distinct_count {
|
||||||
|
errors.push(format!(
|
||||||
|
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
|
||||||
|
total_count, distinct_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan all global slugs
|
||||||
|
let mut stmt = system_conn.prepare(
|
||||||
|
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
||||||
|
)?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let slug: String = row.get(0)?;
|
||||||
|
let owner_user_id: i64 = row.get(1)?;
|
||||||
|
let target_type: String = row.get(2)?;
|
||||||
|
let target_id: String = row.get(3)?;
|
||||||
|
let created_at_str: String = row.get(4)?;
|
||||||
|
let status: String = row.get(5)?;
|
||||||
|
|
||||||
|
// Target type check
|
||||||
|
if target_type != "url" && target_type != "page" {
|
||||||
|
errors.push(format!(
|
||||||
|
"Slug '{}' has invalid target_type '{}'",
|
||||||
|
slug, target_type
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status check
|
||||||
|
if status != "active" && status != "disabled" && status != "reserving" {
|
||||||
|
errors.push(format!("Slug '{}' has invalid status '{}'", slug, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check owner
|
||||||
|
let owner_exists: bool = users_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||||
|
[owner_user_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !owner_exists {
|
||||||
|
errors.push(format!(
|
||||||
|
"Slug '{}' references missing owner user ID {}",
|
||||||
|
slug, owner_user_id
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale warning check
|
||||||
|
if status == "reserving" {
|
||||||
|
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||||
|
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||||
|
if age > chrono::Duration::minutes(15) {
|
||||||
|
warnings.push(format!(
|
||||||
|
"Reserving slug '{}' has been stale for over 15 minutes",
|
||||||
|
slug
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check target record exists for active / disabled (and reserving with target_id)
|
||||||
|
if status == "active"
|
||||||
|
|| status == "disabled"
|
||||||
|
|| (status == "reserving" && !target_id.is_empty())
|
||||||
|
{
|
||||||
|
let content_db_path = if owner_user_id == 1 {
|
||||||
|
let p1 = data_dir.join("users").join("1").join("content.db");
|
||||||
|
if p1.exists() {
|
||||||
|
p1
|
||||||
|
} else {
|
||||||
|
data_dir.join("content.db")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data_dir
|
||||||
|
.join("users")
|
||||||
|
.join(owner_user_id.to_string())
|
||||||
|
.join("content.db")
|
||||||
|
};
|
||||||
|
|
||||||
|
if !content_db_path.exists() {
|
||||||
|
errors.push(format!(
|
||||||
|
"Slug '{}' owner content database does not exist at {:?}",
|
||||||
|
slug, content_db_path
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
match Connection::open(&content_db_path) {
|
||||||
|
Ok(conn) => {
|
||||||
|
let exists = if target_type == "url" {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||||
|
[&target_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else if target_type == "page" {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||||
|
[&target_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"Slug '{}' owner content database could not be opened: {}",
|
||||||
|
slug, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((errors, warnings))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_restored_user_slugs(
|
||||||
|
system_conn: &Connection,
|
||||||
|
target_user_id: i64,
|
||||||
|
restored_content_db_path: &std::path::Path,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let restored_content_conn = Connection::open(restored_content_db_path)?;
|
||||||
|
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
let mut landing_pages = Vec::new();
|
||||||
|
|
||||||
|
// 1. Read URLs
|
||||||
|
{
|
||||||
|
let mut stmt =
|
||||||
|
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let code: String = row.get(0)?;
|
||||||
|
let id: String = row.get(1)?;
|
||||||
|
let created_at: String = row.get(2)?;
|
||||||
|
let status: String = row.get(3)?;
|
||||||
|
urls.push((code, id, created_at, status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read Landing Pages
|
||||||
|
{
|
||||||
|
let mut stmt = restored_content_conn
|
||||||
|
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let code: String = row.get(0)?;
|
||||||
|
let id: String = row.get(1)?;
|
||||||
|
let created_at: String = row.get(2)?;
|
||||||
|
let state: String = row.get(3)?;
|
||||||
|
landing_pages.push((code, id, created_at, state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check for collisions across all URLs and landing pages
|
||||||
|
let mut conflicting_slugs = Vec::new();
|
||||||
|
for (slug, _, _, _) in &urls {
|
||||||
|
let existing_owner: Option<i64> = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||||
|
[slug],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
if let Some(owner) = existing_owner {
|
||||||
|
if owner != target_user_id {
|
||||||
|
conflicting_slugs.push(slug.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (slug, _, _, _) in &landing_pages {
|
||||||
|
let existing_owner: Option<i64> = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||||
|
[slug],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
if let Some(owner) = existing_owner {
|
||||||
|
if owner != target_user_id {
|
||||||
|
conflicting_slugs.push(slug.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !conflicting_slugs.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"Restore failed. Conflicting slugs: {}",
|
||||||
|
conflicting_slugs.join(", ")
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Perform registration
|
||||||
|
system_conn.execute(
|
||||||
|
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
|
||||||
|
[target_user_id],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for (slug, target_id, created_at, status) in urls {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let global_status = if status == "dead" {
|
||||||
|
"disabled"
|
||||||
|
} else {
|
||||||
|
"active"
|
||||||
|
};
|
||||||
|
system_conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||||
|
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
|
||||||
|
rusqlite::params![slug, target_user_id, target_id, created_at, now, global_status],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (slug, target_id, created_at, state) in landing_pages {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let status = if state == "published" {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
};
|
||||||
|
system_conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||||
|
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
|
||||||
|
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reconcile_user_quotas(
|
pub fn reconcile_user_quotas(
|
||||||
users_conn: &Connection,
|
users_conn: &Connection,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub struct UrlAnalyticsTemplate {
|
|||||||
pub page_end: usize,
|
pub page_end: usize,
|
||||||
pub date_from: Option<String>,
|
pub date_from: Option<String>,
|
||||||
pub date_to: Option<String>,
|
pub date_to: Option<String>,
|
||||||
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UrlAnalyticsTemplate {
|
impl UrlAnalyticsTemplate {
|
||||||
@@ -84,6 +85,7 @@ pub struct PageAnalyticsTemplate {
|
|||||||
pub page_end: usize,
|
pub page_end: usize,
|
||||||
pub date_from: Option<String>,
|
pub date_from: Option<String>,
|
||||||
pub date_to: Option<String>,
|
pub date_to: Option<String>,
|
||||||
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PageAnalyticsTemplate {
|
impl PageAnalyticsTemplate {
|
||||||
|
|||||||
+2
-46
@@ -276,6 +276,8 @@ pub struct HealthTemplate {
|
|||||||
pub tenants_db_size: String,
|
pub tenants_db_size: String,
|
||||||
pub job_history: Vec<crate::web::admin::JobHistoryRow>,
|
pub job_history: Vec<crate::web::admin::JobHistoryRow>,
|
||||||
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
|
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
|
||||||
|
pub registry_errors: Vec<String>,
|
||||||
|
pub registry_warnings: Vec<String>,
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
pub success: Option<String>,
|
pub success: Option<String>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
@@ -372,49 +374,3 @@ impl IntoResponse for UserAnalyticsTemplate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "user_url_analytics.html")]
|
|
||||||
pub struct UserUrlAnalyticsTemplate {
|
|
||||||
pub admin_username: String,
|
|
||||||
pub username: String,
|
|
||||||
pub url_code: String,
|
|
||||||
pub destination: String,
|
|
||||||
pub visits: Vec<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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1046
-162
File diff suppressed because it is too large.
Load diff
+209
-45
@@ -149,20 +149,105 @@ pub async fn api_create_url(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Dynamically resolve target user ID and content DB
|
||||||
|
let (target_user_id, content_db) = match user.0 {
|
||||||
|
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||||
|
crate::models::ApiActor::User(ref u) => {
|
||||||
|
let user_dbs = match state.get_user_dbs(u.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "Database error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(u.id, user_dbs.content.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check quota
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "urls")
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "Quota limit exceeded".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||||
|
return (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "Short code already exists".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
&code,
|
||||||
|
target_user_id,
|
||||||
|
"url",
|
||||||
|
"",
|
||||||
|
"reserving",
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ApiError {
|
||||||
|
error: format!("Failed to reserve slug: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let tags = payload.tags.unwrap_or_default();
|
let tags = payload.tags.unwrap_or_default();
|
||||||
let conn = state.content_db.lock().unwrap();
|
let res = {
|
||||||
match crate::db::content::create_url_extended(
|
let conn = content_db.lock().unwrap();
|
||||||
&conn,
|
crate::db::content::create_url_extended(
|
||||||
&code,
|
&conn,
|
||||||
&dest,
|
&code,
|
||||||
payload.title.as_deref(),
|
&dest,
|
||||||
payload.description.as_deref(),
|
payload.title.as_deref(),
|
||||||
&tags,
|
payload.description.as_deref(),
|
||||||
payload.expires_at.as_deref(),
|
&tags,
|
||||||
password_hash.as_deref(),
|
payload.expires_at.as_deref(),
|
||||||
payload.max_access_count,
|
password_hash.as_deref(),
|
||||||
) {
|
payload.max_access_count,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
|
// Activate slug
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||||
|
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Increment quota
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
let _ =
|
||||||
|
crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
|
||||||
|
}
|
||||||
|
|
||||||
let ip = get_client_ip(&headers, connect_info);
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||||
let _ = write_audit_log(
|
let _ = write_audit_log(
|
||||||
@@ -189,24 +274,17 @@ pub async fn api_create_url(
|
|||||||
}
|
}
|
||||||
(StatusCode::CREATED, Json(url)).into_response()
|
(StatusCode::CREATED, Json(url)).into_response()
|
||||||
}
|
}
|
||||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
Err(e) => {
|
||||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
{
|
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||||
(
|
(
|
||||||
StatusCode::CONFLICT,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(ApiError {
|
Json(ApiError {
|
||||||
error: "Short code already exists".to_string(),
|
error: e.to_string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
Err(e) => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(ApiError {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,16 +512,109 @@ pub async fn api_create_page(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let conn = state.content_db.lock().unwrap();
|
// Dynamically resolve target user ID and content DB
|
||||||
match create_landing_page(
|
let (target_user_id, content_db) = match user.0 {
|
||||||
&conn,
|
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||||
&code,
|
crate::models::ApiActor::User(ref u) => {
|
||||||
&payload.slug,
|
let user_dbs = match state.get_user_dbs(u.id) {
|
||||||
&payload.title,
|
Ok(dbs) => dbs,
|
||||||
&payload.html_content,
|
Err(_) => {
|
||||||
&payload.state,
|
return (
|
||||||
) {
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "Database error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(u.id, user_dbs.content.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check quota
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "landings")
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "Quota limit exceeded".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||||
|
return (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "Short code already exists".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
&code,
|
||||||
|
target_user_id,
|
||||||
|
"page",
|
||||||
|
"",
|
||||||
|
"reserving",
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ApiError {
|
||||||
|
error: format!("Failed to reserve slug: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = {
|
||||||
|
let conn = content_db.lock().unwrap();
|
||||||
|
create_landing_page(
|
||||||
|
&conn,
|
||||||
|
&code,
|
||||||
|
&payload.slug,
|
||||||
|
&payload.title,
|
||||||
|
&payload.html_content,
|
||||||
|
&payload.state,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
Ok(page) => {
|
Ok(page) => {
|
||||||
|
// Activate slug
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let global_status = if payload.state == "published" {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
};
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||||
|
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Increment quota
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = crate::db::users::increment_quota_counter(
|
||||||
|
&users_conn,
|
||||||
|
target_user_id,
|
||||||
|
"landings",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let ip = get_client_ip(&headers, connect_info);
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||||
let _ = write_audit_log(
|
let _ = write_audit_log(
|
||||||
@@ -457,24 +628,17 @@ pub async fn api_create_page(
|
|||||||
);
|
);
|
||||||
(StatusCode::CREATED, Json(page)).into_response()
|
(StatusCode::CREATED, Json(page)).into_response()
|
||||||
}
|
}
|
||||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
Err(e) => {
|
||||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
{
|
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||||
(
|
(
|
||||||
StatusCode::CONFLICT,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(ApiError {
|
Json(ApiError {
|
||||||
error: "Short code already exists".to_string(),
|
error: e.to_string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
Err(e) => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(ApiError {
|
|
||||||
error: e.to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+137
-13
@@ -165,7 +165,53 @@ pub async fn api_bulk_url(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = state.content_db.lock().unwrap();
|
// Dynamically resolve target user ID and content DB
|
||||||
|
let (target_user_id, content_db) = match user.0 {
|
||||||
|
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||||
|
crate::models::ApiActor::User(ref u) => {
|
||||||
|
let user_dbs = match state.get_user_dbs(u.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(BulkErrorResponse {
|
||||||
|
error: "Database error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(u.id, user_dbs.content.clone())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check quota
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
if let Some(quotas) =
|
||||||
|
crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None)
|
||||||
|
{
|
||||||
|
if quotas.current_urls + (payload.len() as i64) > quotas.max_urls {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(BulkErrorResponse {
|
||||||
|
error: "Quota limit exceeded".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(BulkErrorResponse {
|
||||||
|
error: "User quota not found".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = content_db.lock().unwrap();
|
||||||
let tx = match conn.transaction() {
|
let tx = match conn.transaction() {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -180,6 +226,7 @@ pub async fn api_bulk_url(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut created_urls = Vec::new();
|
let mut created_urls = Vec::new();
|
||||||
|
let mut reserved_slugs: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for item in payload {
|
for item in payload {
|
||||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||||
@@ -188,6 +235,12 @@ pub async fn api_bulk_url(
|
|||||||
} else {
|
} else {
|
||||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
let _ = tx.rollback();
|
let _ = tx.rollback();
|
||||||
|
// Release reserving slugs
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
for slug in &reserved_slugs {
|
||||||
|
let _ =
|
||||||
|
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(BulkErrorResponse {
|
Json(BulkErrorResponse {
|
||||||
@@ -198,11 +251,66 @@ pub async fn api_bulk_url(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reserve slug
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
// Check availability in system.db and also check in our currently reserved slugs in this batch
|
||||||
|
let available = crate::db::users::is_slug_available(&system_conn, &code)
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& !reserved_slugs.contains(&code);
|
||||||
|
|
||||||
|
if !available {
|
||||||
|
let _ = tx.rollback();
|
||||||
|
for slug in &reserved_slugs {
|
||||||
|
let _ =
|
||||||
|
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(BulkErrorResponse {
|
||||||
|
error: format!("Short code '{}' already exists", code),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = crate::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
&code,
|
||||||
|
target_user_id,
|
||||||
|
"url",
|
||||||
|
"",
|
||||||
|
"reserving",
|
||||||
|
) {
|
||||||
|
let _ = tx.rollback();
|
||||||
|
for slug in &reserved_slugs {
|
||||||
|
let _ =
|
||||||
|
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(BulkErrorResponse {
|
||||||
|
error: format!("Failed to reserve slug '{}': {}", code, e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
reserved_slugs.push(code.clone());
|
||||||
|
}
|
||||||
|
|
||||||
let password_hash = if let Some(ref pwd) = item.password {
|
let password_hash = if let Some(ref pwd) = item.password {
|
||||||
match hash_password(pwd) {
|
match hash_password(pwd) {
|
||||||
Ok(h) => Some(h),
|
Ok(h) => Some(h),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.rollback();
|
let _ = tx.rollback();
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
for slug in &reserved_slugs {
|
||||||
|
let _ = crate::db::users::release_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
slug,
|
||||||
|
target_user_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(BulkErrorResponse {
|
Json(BulkErrorResponse {
|
||||||
@@ -229,20 +337,13 @@ pub async fn api_bulk_url(
|
|||||||
item.max_access_count,
|
item.max_access_count,
|
||||||
) {
|
) {
|
||||||
Ok(url) => created_urls.push(url),
|
Ok(url) => created_urls.push(url),
|
||||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
|
||||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
|
||||||
{
|
|
||||||
let _ = tx.rollback();
|
|
||||||
return (
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
Json(BulkErrorResponse {
|
|
||||||
error: format!("Short code '{}' already exists", code),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.rollback();
|
let _ = tx.rollback();
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
for slug in &reserved_slugs {
|
||||||
|
let _ =
|
||||||
|
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(BulkErrorResponse {
|
Json(BulkErrorResponse {
|
||||||
@@ -255,6 +356,10 @@ pub async fn api_bulk_url(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = tx.commit() {
|
if let Err(e) = tx.commit() {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
for slug in &reserved_slugs {
|
||||||
|
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(BulkErrorResponse {
|
Json(BulkErrorResponse {
|
||||||
@@ -264,6 +369,25 @@ pub async fn api_bulk_url(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activate slugs
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
for url in &created_urls {
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||||
|
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment quota counters
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
for _ in 0..created_urls.len() {
|
||||||
|
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Write Audit Log for the entire batch
|
// Write Audit Log for the entire batch
|
||||||
let ip = get_client_ip(&headers, connect_info);
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||||
|
|||||||
+10
-5
@@ -63,14 +63,19 @@ pub async fn resolve_page(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get user specific database connections
|
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
|
||||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
// tenant users use per-user content databases
|
||||||
Ok(dbs) => dbs,
|
let content_conn = if owner_user_id == 1 {
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
state.content_db.clone()
|
||||||
|
} else {
|
||||||
|
match state.get_user_dbs(owner_user_id) {
|
||||||
|
Ok(dbs) => dbs.content,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let page_opt = {
|
let page_opt = {
|
||||||
let conn = user_dbs.content.lock().unwrap();
|
let conn = content_conn.lock().unwrap();
|
||||||
match crate::db::content::get_landing_page_by_code(&conn, &code) {
|
match crate::db::content::get_landing_page_by_code(&conn, &code) {
|
||||||
Ok(page) => page,
|
Ok(page) => page,
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
|||||||
+59
-71
@@ -10,7 +10,6 @@ use std::net::SocketAddr;
|
|||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
|
||||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||||
pub async fn qr_handler(
|
pub async fn qr_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -38,12 +37,12 @@ pub async fn qr_handler(
|
|||||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to look up owner_user_id and status from global_slugs
|
// We need to look up owner_user_id, target_id, and status from global_slugs
|
||||||
let (owner_user_id, slug_status) = {
|
let (owner_user_id, target_id, slug_status) = {
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
let mut stmt = match system_conn
|
let mut stmt = match system_conn.prepare(
|
||||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
"SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||||
{
|
) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||||
@@ -52,19 +51,25 @@ pub async fn qr_handler(
|
|||||||
use rusqlite::OptionalExtension;
|
use rusqlite::OptionalExtension;
|
||||||
match stmt
|
match stmt
|
||||||
.query_row(rusqlite::params![&file], |row| {
|
.query_row(rusqlite::params![&file], |row| {
|
||||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
{
|
{
|
||||||
Ok(Some((uid, status))) => (uid, status),
|
Ok(Some((uid, tid, status))) => (uid, tid, status),
|
||||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if slug_status != "active" {
|
if slug_status == "disabled" {
|
||||||
|
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||||
|
} else if slug_status != "active" {
|
||||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,31 +78,16 @@ pub async fn qr_handler(
|
|||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let url_opt = {
|
|
||||||
let conn = user_dbs.content.lock().unwrap();
|
|
||||||
match crate::db::content::get_url_by_code(&conn, &file) {
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(_) => {
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = match url_opt {
|
|
||||||
Some(u) => u,
|
|
||||||
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let qr_scans = {
|
let qr_scans = {
|
||||||
let conn = user_dbs.analytics.lock().unwrap();
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
|
crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
|
||||||
};
|
};
|
||||||
|
|
||||||
let direct_clicks = {
|
let direct_clicks = {
|
||||||
let conn = user_dbs.analytics.lock().unwrap();
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
|
"SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
|
||||||
rusqlite::params![url.id],
|
rusqlite::params![target_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
@@ -113,15 +103,17 @@ pub async fn qr_handler(
|
|||||||
let code = parts[0];
|
let code = parts[0];
|
||||||
let ext = parts[1].to_lowercase();
|
let ext = parts[1].to_lowercase();
|
||||||
|
|
||||||
if !crate::utils::validation::validate_redirect_code(code) {
|
if !crate::utils::validation::validate_redirect_code(code)
|
||||||
|
&& !crate::utils::validation::validate_page_code(code)
|
||||||
|
{
|
||||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to look up owner_user_id and status from global_slugs
|
// We need to look up owner_user_id, target_type, target_id, and status from global_slugs
|
||||||
let (owner_user_id, slug_status) = {
|
let (owner_user_id, target_type, target_id, slug_status) = {
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
let mut stmt = match system_conn
|
let mut stmt = match system_conn
|
||||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
.prepare("SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;")
|
||||||
{
|
{
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
@@ -129,38 +121,27 @@ pub async fn qr_handler(
|
|||||||
use rusqlite::OptionalExtension;
|
use rusqlite::OptionalExtension;
|
||||||
match stmt
|
match stmt
|
||||||
.query_row(rusqlite::params![code], |row| {
|
.query_row(rusqlite::params![code], |row| {
|
||||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
{
|
{
|
||||||
Ok(Some((uid, status))) => (uid, status),
|
Ok(Some(info)) => info,
|
||||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if slug_status != "active" {
|
if slug_status == "disabled" {
|
||||||
return (StatusCode::NOT_FOUND, "Url not found").into_response();
|
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||||
|
} else if slug_status != "active" {
|
||||||
|
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
|
||||||
Ok(dbs) => dbs,
|
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url_opt = {
|
|
||||||
let conn = user_dbs.content.lock().unwrap();
|
|
||||||
match crate::db::content::get_url_by_code(&conn, code) {
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = match url_opt {
|
|
||||||
Some(u) => u,
|
|
||||||
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Construct public base URL
|
// Construct public base URL
|
||||||
let proto = if state.config.cookie_secure {
|
let proto = if state.config.cookie_secure {
|
||||||
"https"
|
"https"
|
||||||
@@ -178,7 +159,11 @@ pub async fn qr_handler(
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||||
|
|
||||||
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code);
|
let full_url = if target_type == "page" {
|
||||||
|
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", base_url.trim_end_matches('/'), code)
|
||||||
|
};
|
||||||
|
|
||||||
// Generate QR code based on format
|
// Generate QR code based on format
|
||||||
let (body, content_type) = if ext == "svg" {
|
let (body, content_type) = if ext == "svg" {
|
||||||
@@ -211,22 +196,25 @@ pub async fn qr_handler(
|
|||||||
.into_response();
|
.into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log the QR access event
|
// Log the QR access event in a try-catch style
|
||||||
let ip = get_client_ip(&headers, connect_info);
|
let _ = {
|
||||||
let user_agent = headers
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
.get("user-agent")
|
let user_agent = headers
|
||||||
.and_then(|h| h.to_str().ok())
|
.get("user-agent")
|
||||||
.map(|s| s.to_string());
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
{
|
if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
|
||||||
let analytics_conn = user_dbs.analytics.lock().unwrap();
|
if let Ok(analytics_conn) = user_dbs.analytics.lock() {
|
||||||
let _ = crate::db::qr::log_qr_access(
|
let _ = crate::db::qr::log_qr_access(
|
||||||
&analytics_conn,
|
&analytics_conn,
|
||||||
&url.id,
|
&target_id,
|
||||||
Some(ip.as_str()),
|
Some(ip.as_str()),
|
||||||
user_agent.as_deref(),
|
user_agent.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.header("content-type", content_type)
|
.header("content-type", content_type)
|
||||||
|
|||||||
+32
-12
@@ -24,8 +24,10 @@ pub async fn resolve_redirect(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// Basic validation of code (must be 6 hex characters or a valid custom slug)
|
// Basic validation of code (must be 6 hex characters, 4 hex characters, or a valid custom slug)
|
||||||
if !crate::utils::validation::validate_redirect_code(&code) {
|
if !crate::utils::validation::validate_redirect_code(&code)
|
||||||
|
&& !crate::utils::validation::validate_page_code(&code)
|
||||||
|
{
|
||||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ pub async fn resolve_redirect(
|
|||||||
.optional()
|
.optional()
|
||||||
};
|
};
|
||||||
|
|
||||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||||
Ok(Some(info)) => info,
|
Ok(Some(info)) => info,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||||
@@ -67,14 +69,24 @@ pub async fn resolve_redirect(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get user specific database connections
|
// If target type is page, redirect permanently to /p/slug
|
||||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
if target_type == "page" {
|
||||||
Ok(dbs) => dbs,
|
return Redirect::permanent(&format!("/p/{}", code)).into_response();
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
}
|
||||||
|
|
||||||
|
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
|
||||||
|
// tenant users use per-user content databases
|
||||||
|
let content_conn = if owner_user_id == 1 {
|
||||||
|
state.content_db.clone()
|
||||||
|
} else {
|
||||||
|
match state.get_user_dbs(owner_user_id) {
|
||||||
|
Ok(dbs) => dbs.content,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let url_opt = {
|
let url_opt = {
|
||||||
let conn = user_dbs.content.lock().unwrap();
|
let conn = content_conn.lock().unwrap();
|
||||||
match crate::db::content::get_url_by_code(&conn, &code) {
|
match crate::db::content::get_url_by_code(&conn, &code) {
|
||||||
Ok(url) => url,
|
Ok(url) => url,
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
@@ -96,7 +108,7 @@ pub async fn resolve_redirect(
|
|||||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||||
// Mark as expired in DB asynchronously/immediately
|
// Mark as expired in DB asynchronously/immediately
|
||||||
{
|
{
|
||||||
let conn = user_dbs.content.lock().unwrap();
|
let conn = content_conn.lock().unwrap();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||||
[url.id.clone()],
|
[url.id.clone()],
|
||||||
@@ -131,12 +143,12 @@ pub async fn resolve_redirect(
|
|||||||
|
|
||||||
// 6. Increment access count & retrieve preview config
|
// 6. Increment access count & retrieve preview config
|
||||||
let _new_access_count = {
|
let _new_access_count = {
|
||||||
let conn = user_dbs.content.lock().unwrap();
|
let conn = content_conn.lock().unwrap();
|
||||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
let preview_opt = {
|
let preview_opt = {
|
||||||
let conn = user_dbs.content.lock().unwrap();
|
let conn = content_conn.lock().unwrap();
|
||||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,6 +200,14 @@ pub async fn resolve_redirect(
|
|||||||
}
|
}
|
||||||
.into_response()
|
.into_response()
|
||||||
} else {
|
} else {
|
||||||
Redirect::temporary(&url.destination).into_response()
|
{
|
||||||
|
use axum::http::{header, HeaderValue};
|
||||||
|
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
|
||||||
|
resp.headers_mut().insert(
|
||||||
|
header::LOCATION,
|
||||||
|
HeaderValue::from_str(&url.destination).unwrap(),
|
||||||
|
);
|
||||||
|
resp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,10 +52,26 @@ pub fn create_router(state: AppState) -> Router {
|
|||||||
"/user/analytics/url/:id",
|
"/user/analytics/url/:id",
|
||||||
get(admin::user_url_analytics_get),
|
get(admin::user_url_analytics_get),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/user/analytics/url/:id/export/csv",
|
||||||
|
get(admin::user_url_analytics_csv_export),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/user/analytics/url/:id/export/json",
|
||||||
|
get(admin::user_url_analytics_json_export),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/user/analytics/page/:id",
|
"/user/analytics/page/:id",
|
||||||
get(admin::user_page_analytics_get),
|
get(admin::user_page_analytics_get),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/user/analytics/page/:id/export/csv",
|
||||||
|
get(admin::user_page_analytics_csv_export),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/user/analytics/page/:id/export/json",
|
||||||
|
get(admin::user_page_analytics_json_export),
|
||||||
|
)
|
||||||
.route("/api-tokens", get(admin::api_tokens_get))
|
.route("/api-tokens", get(admin::api_tokens_get))
|
||||||
.route("/api-tokens/create", post(admin::api_tokens_create_post))
|
.route("/api-tokens/create", post(admin::api_tokens_create_post))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<td style="text-align: center; vertical-align: middle;">
|
||||||
|
<a href="/api/qr/{{ code }}.png" target="_blank" title="View QR Code">
|
||||||
|
<img src="/api/qr/{{ code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
|
||||||
|
</a>
|
||||||
|
<div style="margin-top: 0.25rem; display: flex; gap: 0.25rem; justify-content: center;">
|
||||||
|
<a href="/api/qr/{{ code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
|
||||||
|
<a href="/api/qr/{{ code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
@@ -7,6 +7,38 @@
|
|||||||
{% block header_title %}System Health Dashboard{% endblock %}
|
{% block header_title %}System Health Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if !registry_errors.is_empty() || !registry_warnings.is_empty() %}
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr; gap: 1rem; margin-bottom: 1.5rem;">
|
||||||
|
{% if !registry_errors.is_empty() %}
|
||||||
|
<div class="card" style="border: 1px solid var(--dead-color); background-color: rgba(220, 53, 69, 0.1); padding: 1.5rem;">
|
||||||
|
<h3 style="font-size: 1.15rem; color: var(--dead-color); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
Global Registry Errors (Action Required)
|
||||||
|
</h3>
|
||||||
|
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
{% for err in registry_errors %}
|
||||||
|
<li>{{ err }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if !registry_warnings.is_empty() %}
|
||||||
|
<div class="card" style="border: 1px solid #ffc107; background-color: rgba(255, 193, 7, 0.1); padding: 1.5rem;">
|
||||||
|
<h3 style="font-size: 1.15rem; color: #ffc107; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
Global Registry Warnings (Attention Needed)
|
||||||
|
</h3>
|
||||||
|
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
|
||||||
|
{% for warn in registry_warnings %}
|
||||||
|
<li>{{ warn }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
|
||||||
<!-- DB Health Report -->
|
<!-- DB Health Report -->
|
||||||
<div class="card" style="padding: 0; overflow: hidden;">
|
<div class="card" style="padding: 0; overflow: hidden;">
|
||||||
|
|||||||
+104
-10
@@ -4,17 +4,111 @@
|
|||||||
|
|
||||||
{% block active_pages %}active{% endblock %}
|
{% block active_pages %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar_links %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<li class="{% block active_dashboard %}{% endblock %}">
|
||||||
|
<a href="/admin/dashboard">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/urls">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
Short URLs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="active">
|
||||||
|
<a href="/admin/pages">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
Landing Pages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/users">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
Users Management
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/settings">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1-1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/audit">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
|
Audit Log
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/status">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||||
|
Status
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>
|
||||||
|
<a href="/user/dashboard">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user/urls">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
Short URLs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="active">
|
||||||
|
<a href="/user/pages">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
Landing Pages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user/settings">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user/audit">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
|
Audit Log
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user/status">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||||
|
Status
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar_footer %}
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="admin-user-info">
|
||||||
|
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
|
||||||
|
<span>{{ admin_username }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="{% if is_admin %}/admin/logout{% else %}/logout{% endif %}" class="logout-btn">Log Out</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %}
|
{% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %}
|
||||||
|
|
||||||
{% block header_actions %}
|
{% block header_actions %}
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<a href="/admin/analytics/page/{{ page.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}/export/csv{% else %}/user/analytics/page/{{ page.id }}/export/csv{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||||
📥 Export CSV
|
📥 Export CSV
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/analytics/page/{{ page.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}/export/json{% else %}/user/analytics/page/{{ page.id }}/export/json{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||||
📥 Export JSON
|
📥 Export JSON
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
<a href="{% if is_admin %}/admin/pages{% else %}/user/pages{% endif %}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
Back to Landing Pages
|
Back to Landing Pages
|
||||||
</a>
|
</a>
|
||||||
@@ -24,7 +118,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Date Filter Form -->
|
<!-- Date Filter Form -->
|
||||||
<div class="card" style="margin-bottom: 2rem;">
|
<div class="card" style="margin-bottom: 2rem;">
|
||||||
<form method="GET" action="/admin/analytics/page/{{ page.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
<form method="GET" action="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
||||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
||||||
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
|
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
|
||||||
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||||
@@ -34,7 +128,7 @@
|
|||||||
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
|
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
|
||||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,8 +289,8 @@
|
|||||||
|
|
||||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
||||||
{% if current_page > 1 %}
|
{% if current_page > 1 %}
|
||||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||||
@@ -206,13 +300,13 @@
|
|||||||
{% if self.is_current(p) %}
|
{% if self.is_current(p) %}
|
||||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if current_page < total_pages %}
|
{% if current_page < total_pages %}
|
||||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
<th>SEO Preview Path</th>
|
<th>SEO Preview Path</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Analytics</th>
|
<th>Analytics</th>
|
||||||
|
<th>QR Code</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% if pages.is_empty() %}
|
{% if pages.is_empty() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||||
No landing pages registered. Create one to get started!
|
No landing pages registered. Create one to get started!
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -126,6 +127,8 @@
|
|||||||
📊 Analytics
|
📊 Analytics
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
{% let code = page.code.as_str() %}
|
||||||
|
{% include "components/qr_preview.html" %}
|
||||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||||
{{ page.created_at[0..10] }}
|
{{ page.created_at[0..10] }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+104
-10
@@ -4,17 +4,111 @@
|
|||||||
|
|
||||||
{% block active_urls %}active{% endblock %}
|
{% block active_urls %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar_links %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<li class="{% block active_dashboard %}{% endblock %}">
|
||||||
|
<a href="/admin/dashboard">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="active">
|
||||||
|
<a href="/admin/urls">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
Short URLs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/pages">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
Landing Pages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/users">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
Users Management
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/settings">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/audit">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
|
Audit Log
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/status">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||||
|
Status
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>
|
||||||
|
<a href="/user/dashboard">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="active">
|
||||||
|
<a href="/user/urls">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
Short URLs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user/pages">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
Landing Pages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user/settings">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user/audit">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
|
Audit Log
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/user/status">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||||
|
Status
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar_footer %}
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="admin-user-info">
|
||||||
|
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
|
||||||
|
<span>{{ admin_username }}</span>
|
||||||
|
</div>
|
||||||
|
<a href="{% if is_admin %}/admin/logout{% else %}/logout{% endif %}" class="logout-btn">Log Out</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block header_title %}URL Analytics: /{{ url.code }}{% endblock %}
|
{% block header_title %}URL Analytics: /{{ url.code }}{% endblock %}
|
||||||
|
|
||||||
{% block header_actions %}
|
{% block header_actions %}
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<a href="/admin/analytics/url/{{ url.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}/export/csv{% else %}/user/analytics/url/{{ url.id }}/export/csv{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||||
📥 Export CSV
|
📥 Export CSV
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/analytics/url/{{ url.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}/export/json{% else %}/user/analytics/url/{{ url.id }}/export/json{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||||
📥 Export JSON
|
📥 Export JSON
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
<a href="{% if is_admin %}/admin/urls{% else %}/user/urls{% endif %}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
Back to URLs
|
Back to URLs
|
||||||
</a>
|
</a>
|
||||||
@@ -24,7 +118,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Date Filter Form -->
|
<!-- Date Filter Form -->
|
||||||
<div class="card" style="margin-bottom: 2rem;">
|
<div class="card" style="margin-bottom: 2rem;">
|
||||||
<form method="GET" action="/admin/analytics/url/{{ url.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
<form method="GET" action="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
||||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
||||||
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
|
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
|
||||||
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||||
@@ -34,7 +128,7 @@
|
|||||||
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
|
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
|
||||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -224,8 +318,8 @@
|
|||||||
|
|
||||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
||||||
{% if current_page > 1 %}
|
{% if current_page > 1 %}
|
||||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||||
@@ -235,13 +329,13 @@
|
|||||||
{% if self.is_current(p) %}
|
{% if self.is_current(p) %}
|
||||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if current_page < total_pages %}
|
{% if current_page < total_pages %}
|
||||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||||
|
|||||||
+2
-9
@@ -191,15 +191,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: center; vertical-align: middle;">
|
{% let code = url.code.as_str() %}
|
||||||
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
|
{% include "components/qr_preview.html" %}
|
||||||
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
|
|
||||||
</a>
|
|
||||||
<div style="margin-top: 0.25rem; display: flex; gap: 0.25rem; justify-content: center;">
|
|
||||||
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
|
|
||||||
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-{{ url.status }}">
|
<span class="badge badge-{{ url.status }}">
|
||||||
{{ url.status }}
|
{{ url.status }}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
{% extends "user_layout.html" %}
|
|
||||||
|
|
||||||
{% block title %}Landing Page Analytics - /p/{{ page_code }} - BZOD{% endblock %}
|
|
||||||
|
|
||||||
{% block active_pages %}active{% endblock %}
|
|
||||||
|
|
||||||
{% block header_title %}Landing Page Analytics: /p/{{ page_code }}{% endblock %}
|
|
||||||
|
|
||||||
{% block header_actions %}
|
|
||||||
<a href="/user/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
|
||||||
Back to Landing Pages
|
|
||||||
</a>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Page Details Card -->
|
|
||||||
<div class="card" style="margin-bottom: 2rem;">
|
|
||||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
||||||
Landing Page Details
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Page Title</span>
|
|
||||||
<span style="font-weight: 600; font-size: 1.1rem; color: var(--text-primary);">{{ title }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Visitor Activity Log -->
|
|
||||||
<div class="card" style="padding: 0; overflow: hidden;">
|
|
||||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
|
||||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
||||||
Visitor Activity Log
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Sr</th>
|
|
||||||
<th>Timestamp</th>
|
|
||||||
<th>IP Address</th>
|
|
||||||
<th>Country</th>
|
|
||||||
<th>Referrer</th>
|
|
||||||
<th>Browser</th>
|
|
||||||
<th>User-Agent</th>
|
|
||||||
<th>UTM Source</th>
|
|
||||||
<th>UTM Campaign</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% if visits.is_empty() %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
|
||||||
No visitor activity available for this landing page.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
{% for entry in visits %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ entry.sr }}</td>
|
|
||||||
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
|
|
||||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
|
|
||||||
<td>
|
|
||||||
{% if entry.country == "Unknown" %}
|
|
||||||
<span style="color: var(--text-muted);">Unknown</span>
|
|
||||||
{% else %}
|
|
||||||
{{ entry.country }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ entry.referrer }}</td>
|
|
||||||
<td>{{ entry.browser }}</td>
|
|
||||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
|
|
||||||
{{ entry.user_agent }}
|
|
||||||
</td>
|
|
||||||
<td>{{ entry.utm_source }}</td>
|
|
||||||
<td>{{ entry.utm_campaign }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
<th>SEO Preview Path</th>
|
<th>SEO Preview Path</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Analytics</th>
|
<th>Analytics</th>
|
||||||
|
<th>QR Code</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% if pages.is_empty() %}
|
{% if pages.is_empty() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||||
No landing pages created yet.
|
No landing pages created yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -114,6 +115,8 @@
|
|||||||
📊 Analytics
|
📊 Analytics
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
{% let code = page.code.as_str() %}
|
||||||
|
{% include "components/qr_preview.html" %}
|
||||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||||
{{ page.created_at[0..10] }}
|
{{ page.created_at[0..10] }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
{% extends "user_layout.html" %}
|
|
||||||
|
|
||||||
{% block title %}Short URL Analytics - /{{ url_code }} - BZOD{% endblock %}
|
|
||||||
|
|
||||||
{% block active_urls %}active{% endblock %}
|
|
||||||
|
|
||||||
{% block header_title %}URL Analytics: /{{ url_code }}{% endblock %}
|
|
||||||
|
|
||||||
{% block header_actions %}
|
|
||||||
<a href="/user/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
|
||||||
Back to URLs
|
|
||||||
</a>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Link Details Card -->
|
|
||||||
<div class="card" style="margin-bottom: 2rem;">
|
|
||||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
||||||
Link Details
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Destination URL</span>
|
|
||||||
<a href="{{ destination }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600; word-break: break-all;">
|
|
||||||
{{ destination }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Visitor Activity Log -->
|
|
||||||
<div class="card" style="padding: 0; overflow: hidden;">
|
|
||||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
|
||||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
||||||
Visitor Activity Log
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Sr</th>
|
|
||||||
<th>Timestamp</th>
|
|
||||||
<th>IP Address</th>
|
|
||||||
<th>Country</th>
|
|
||||||
<th>Referrer</th>
|
|
||||||
<th>Browser</th>
|
|
||||||
<th>User-Agent</th>
|
|
||||||
<th>UTM Source</th>
|
|
||||||
<th>UTM Campaign</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% if visits.is_empty() %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
|
||||||
No visitor activity available for this short link.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
{% for entry in visits %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ entry.sr }}</td>
|
|
||||||
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
|
|
||||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
|
|
||||||
<td>
|
|
||||||
{% if entry.country == "Unknown" %}
|
|
||||||
<span style="color: var(--text-muted);">Unknown</span>
|
|
||||||
{% else %}
|
|
||||||
{{ entry.country }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ entry.referrer }}</td>
|
|
||||||
<td>{{ entry.browser }}</td>
|
|
||||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
|
|
||||||
{{ entry.user_agent }}
|
|
||||||
</td>
|
|
||||||
<td>{{ entry.utm_source }}</td>
|
|
||||||
<td>{{ entry.utm_campaign }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -85,17 +85,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
|
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
|
||||||
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
|
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
|
||||||
<td>
|
{% let code = url.code.as_str() %}
|
||||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;">
|
{% include "components/qr_preview.html" %}
|
||||||
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
|
|
||||||
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
|
|
||||||
</a>
|
|
||||||
<div style="display: flex; gap: 0.25rem;">
|
|
||||||
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
|
|
||||||
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ url.status }}</td>
|
<td>{{ url.status }}</td>
|
||||||
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
|
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use bzod::analytics::AnalyticsQueue;
|
||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use bzod::state::AppState;
|
||||||
|
use bzod::web::create_router;
|
||||||
|
|
||||||
|
fn compute_sha256(value: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.admin_username = "admin".to_string();
|
||||||
|
config.base_url = Some("http://localhost:8080".to_string());
|
||||||
|
config.cookie_secure = false;
|
||||||
|
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_csrf_token(html: &str) -> Option<String> {
|
||||||
|
let marker = "name=\"csrf_token\" value=\"";
|
||||||
|
if let Some(pos) = html.find(marker) {
|
||||||
|
let start = pos + marker.len();
|
||||||
|
if let Some(end) = html[start..].find('"') {
|
||||||
|
return Some(html[start..start + end].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_test_server(
|
||||||
|
temp_dir: PathBuf,
|
||||||
|
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
||||||
|
let config = create_temp_config(temp_dir);
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let queue = AnalyticsQueue::new(db.clone(), 100);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
admin_db: db.admin.clone(),
|
||||||
|
content_db: db.content.clone(),
|
||||||
|
analytics_db: db.analytics.clone(),
|
||||||
|
system_db: db.system.clone(),
|
||||||
|
users_db: db.users.clone(),
|
||||||
|
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||||
|
db: db.clone(),
|
||||||
|
config,
|
||||||
|
analytics_queue: queue,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = create_router(state);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let url = format!("http://{}", addr);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(client, url, handle, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_analytics_and_table_parity() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_parity_test_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let (client, base_url, _server_handle, db) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
// 1. Log in as admin FIRST (Bootstrap phase: zero users exist)
|
||||||
|
let admin_client = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let admin_login_url = format!("{}/admin/login", base_url);
|
||||||
|
let res = admin_client.get(&admin_login_url).send().await.unwrap();
|
||||||
|
let html = res.text().await.unwrap();
|
||||||
|
let csrf_token = extract_csrf_token(&html).unwrap();
|
||||||
|
|
||||||
|
let mut admin_login_params = HashMap::new();
|
||||||
|
admin_login_params.insert("username", "admin");
|
||||||
|
admin_login_params.insert("password", "bootstrap-secret");
|
||||||
|
admin_login_params.insert("csrf_token", &csrf_token);
|
||||||
|
|
||||||
|
let res = admin_client
|
||||||
|
.post(&admin_login_url)
|
||||||
|
.form(&admin_login_params)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
// Admin adds a URL to the global content_db
|
||||||
|
let _admin_url_id = {
|
||||||
|
let conn_admin = db.content.lock().unwrap();
|
||||||
|
let url = bzod::db::content::create_url_extended(
|
||||||
|
&conn_admin,
|
||||||
|
"!admin-slug",
|
||||||
|
"https://admin.com",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"!admin-slug",
|
||||||
|
1,
|
||||||
|
"url",
|
||||||
|
&url.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
url.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin adds a Landing Page to the global content_db
|
||||||
|
let _admin_page_id = {
|
||||||
|
let conn_admin = db.content.lock().unwrap();
|
||||||
|
let page = bzod::db::content::create_landing_page(
|
||||||
|
&conn_admin,
|
||||||
|
"!admin-page",
|
||||||
|
"admin-page",
|
||||||
|
"Title Admin",
|
||||||
|
"<html></html>",
|
||||||
|
"published",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"!admin-page",
|
||||||
|
1,
|
||||||
|
"page",
|
||||||
|
&page.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
page.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Create User A
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("usera".to_string()),
|
||||||
|
Some("password123".to_string()),
|
||||||
|
None,
|
||||||
|
create_temp_config(temp_dir.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let id_a = {
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// User A adds a URL
|
||||||
|
let urla_id = {
|
||||||
|
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||||
|
let url = bzod::db::content::create_url_extended(
|
||||||
|
&conn_a,
|
||||||
|
"!usera-slug",
|
||||||
|
"https://usera.com",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"!usera-slug",
|
||||||
|
id_a,
|
||||||
|
"url",
|
||||||
|
&url.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
url.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// User A adds a Landing Page
|
||||||
|
let pagea_id = {
|
||||||
|
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||||
|
let page = bzod::db::content::create_landing_page(
|
||||||
|
&conn_a,
|
||||||
|
"!usera-page",
|
||||||
|
"usera-page",
|
||||||
|
"Title A",
|
||||||
|
"<html></html>",
|
||||||
|
"published",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"!usera-page",
|
||||||
|
id_a,
|
||||||
|
"page",
|
||||||
|
&page.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
page.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Log in as User A ---
|
||||||
|
let login_url = format!("{}/login", base_url);
|
||||||
|
let res = client.get(&login_url).send().await.unwrap();
|
||||||
|
let html = res.text().await.unwrap();
|
||||||
|
let csrf_token = extract_csrf_token(&html).unwrap();
|
||||||
|
|
||||||
|
let mut login_params = HashMap::new();
|
||||||
|
login_params.insert("username", "usera");
|
||||||
|
login_params.insert("password", "password123");
|
||||||
|
login_params.insert("csrf_token", &csrf_token);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.post(&login_url)
|
||||||
|
.form(&login_params)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
// 1. Get user URLs dashboard and check for QR Code preview
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/user/urls", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
let user_urls_html = res.text().await.unwrap();
|
||||||
|
assert!(user_urls_html.contains("QR Code"));
|
||||||
|
assert!(user_urls_html.contains("/api/qr/!usera-slug.svg"));
|
||||||
|
|
||||||
|
// 2. Get user Pages dashboard and check for QR Code preview
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/user/pages", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
let user_pages_html = res.text().await.unwrap();
|
||||||
|
assert!(user_pages_html.contains("QR Code"));
|
||||||
|
assert!(user_pages_html.contains("/api/qr/!usera-page.svg"));
|
||||||
|
|
||||||
|
// 3. Get user URL analytics and verify dashboard features exist
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/user/analytics/url/{}", base_url, urla_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
let user_url_analytics = res.text().await.unwrap();
|
||||||
|
assert!(user_url_analytics.contains("Export CSV"));
|
||||||
|
assert!(user_url_analytics.contains("Export JSON"));
|
||||||
|
assert!(user_url_analytics.contains("date_from"));
|
||||||
|
assert!(user_url_analytics.contains("Daily Click Traffic"));
|
||||||
|
assert!(user_url_analytics.contains("Referrer Channels"));
|
||||||
|
assert!(user_url_analytics.contains("Browser breakdown"));
|
||||||
|
|
||||||
|
// 4. Get user Page analytics and verify dashboard features exist
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/user/analytics/page/{}", base_url, pagea_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
let user_page_analytics = res.text().await.unwrap();
|
||||||
|
assert!(user_page_analytics.contains("Export CSV"));
|
||||||
|
assert!(user_page_analytics.contains("Export JSON"));
|
||||||
|
assert!(user_page_analytics.contains("date_from"));
|
||||||
|
assert!(user_page_analytics.contains("Daily Page Views"));
|
||||||
|
assert!(user_page_analytics.contains("Referrer Channels"));
|
||||||
|
|
||||||
|
// 5. Get admin URLs dashboard and check for QR Code preview
|
||||||
|
let res = admin_client
|
||||||
|
.get(format!("{}/admin/urls", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
let admin_urls_html = res.text().await.unwrap();
|
||||||
|
assert!(admin_urls_html.contains("QR Code"));
|
||||||
|
assert!(admin_urls_html.contains("/api/qr/!admin-slug.svg"));
|
||||||
|
|
||||||
|
// 6. Get admin Pages dashboard and check for QR Code preview
|
||||||
|
let res = admin_client
|
||||||
|
.get(format!("{}/admin/pages", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
let admin_pages_html = res.text().await.unwrap();
|
||||||
|
assert!(admin_pages_html.contains("QR Code"));
|
||||||
|
assert!(admin_pages_html.contains("/api/qr/!admin-page.svg"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
@@ -135,8 +135,15 @@ async fn test_backup_restore_roundtrip() {
|
|||||||
|
|
||||||
// Register global slug
|
// Register global slug
|
||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!rt-slug", user_id, "url", "rt-id")
|
bzod::db::users::register_global_slug(
|
||||||
.unwrap();
|
&system_conn,
|
||||||
|
"!rt-slug",
|
||||||
|
user_id,
|
||||||
|
"url",
|
||||||
|
"rt-id",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup user
|
// Backup user
|
||||||
@@ -234,8 +241,15 @@ async fn test_restore_slug_collision_rejection() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_a, "url", "a-id")
|
bzod::db::users::register_global_slug(
|
||||||
.unwrap();
|
&system_conn,
|
||||||
|
"!collision-slug",
|
||||||
|
id_a,
|
||||||
|
"url",
|
||||||
|
"a-id",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup User A
|
// Backup User A
|
||||||
@@ -289,18 +303,25 @@ async fn test_restore_slug_collision_rejection() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_b, "url", "b-id")
|
bzod::db::users::register_global_slug(
|
||||||
.unwrap();
|
&system_conn,
|
||||||
|
"!collision-slug",
|
||||||
|
id_b,
|
||||||
|
"url",
|
||||||
|
"b-id",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to restore User A from backup
|
// Attempt to restore User A from backup - must fail on collision
|
||||||
bzod::cli::restore_user::run(
|
let res = bzod::cli::restore_user::run(
|
||||||
backup_file.to_string_lossy().to_string(),
|
backup_file.to_string_lossy().to_string(),
|
||||||
None,
|
None,
|
||||||
config.clone(),
|
config.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.unwrap();
|
assert!(res.is_err());
|
||||||
|
|
||||||
// Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected
|
// Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ async fn test_scenario_a_user_create_login_shorten_visit_analytics() {
|
|||||||
let redir_url = format!("{}/!mygoogle", base_url);
|
let redir_url = format!("{}/!mygoogle", base_url);
|
||||||
let res = client.get(&redir_url).send().await.unwrap();
|
let res = client.get(&redir_url).send().await.unwrap();
|
||||||
// It should redirect to google.com
|
// It should redirect to google.com
|
||||||
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
|
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.headers().get("location").unwrap().to_str().unwrap(),
|
res.headers().get("location").unwrap().to_str().unwrap(),
|
||||||
"https://google.com"
|
"https://google.com"
|
||||||
@@ -573,7 +573,7 @@ async fn test_scenario_c_slug_transfer_workflow() {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
|
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
res.headers().get("location").unwrap().to_str().unwrap(),
|
res.headers().get("location").unwrap().to_str().unwrap(),
|
||||||
"https://yahoo.com"
|
"https://yahoo.com"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async fn test_concurrent_slug_creation() {
|
|||||||
let task1 = tokio::spawn(async move {
|
let task1 = tokio::spawn(async move {
|
||||||
let conn = rusqlite::Connection::open(&path1).unwrap();
|
let conn = rusqlite::Connection::open(&path1).unwrap();
|
||||||
b1.wait().await;
|
b1.wait().await;
|
||||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10")
|
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10", "active")
|
||||||
});
|
});
|
||||||
|
|
||||||
let b2 = barrier.clone();
|
let b2 = barrier.clone();
|
||||||
@@ -38,7 +38,7 @@ async fn test_concurrent_slug_creation() {
|
|||||||
let task2 = tokio::spawn(async move {
|
let task2 = tokio::spawn(async move {
|
||||||
let conn = rusqlite::Connection::open(&path2).unwrap();
|
let conn = rusqlite::Connection::open(&path2).unwrap();
|
||||||
b2.wait().await;
|
b2.wait().await;
|
||||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20")
|
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20", "active")
|
||||||
});
|
});
|
||||||
|
|
||||||
let res1 = task1.await.unwrap();
|
let res1 = task1.await.unwrap();
|
||||||
|
|||||||
@@ -58,8 +58,15 @@ async fn test_global_slug_index_consistency() {
|
|||||||
// Register globally
|
// Register globally
|
||||||
{
|
{
|
||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!integ-slug", user_id, "url", &url_id)
|
bzod::db::users::register_global_slug(
|
||||||
.unwrap();
|
&system_conn,
|
||||||
|
"!integ-slug",
|
||||||
|
user_id,
|
||||||
|
"url",
|
||||||
|
&url_id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consistency Check:
|
// Consistency Check:
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ async fn test_content_flagging_and_disabling() {
|
|||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
// Register slug
|
// Register slug
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc").unwrap();
|
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc", "active")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Verify it is active initially
|
// Verify it is active initially
|
||||||
let status: String = system_conn
|
let status: String = system_conn
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use bzod::analytics::AnalyticsQueue;
|
||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use bzod::state::AppState;
|
||||||
|
use bzod::web::create_router;
|
||||||
|
|
||||||
|
fn compute_sha256(value: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.admin_username = "admin".to_string();
|
||||||
|
config.base_url = Some("http://localhost:8080".to_string());
|
||||||
|
config.cookie_secure = false;
|
||||||
|
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_csrf_token(html: &str) -> Option<String> {
|
||||||
|
let marker = "name=\"csrf_token\" value=\"";
|
||||||
|
if let Some(pos) = html.find(marker) {
|
||||||
|
let start = pos + marker.len();
|
||||||
|
if let Some(end) = html[start..].find('"') {
|
||||||
|
return Some(html[start..start + end].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_test_server(
|
||||||
|
temp_dir: PathBuf,
|
||||||
|
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
||||||
|
let config = create_temp_config(temp_dir);
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let queue = AnalyticsQueue::new(db.clone(), 100);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
admin_db: db.admin.clone(),
|
||||||
|
content_db: db.content.clone(),
|
||||||
|
analytics_db: db.analytics.clone(),
|
||||||
|
system_db: db.system.clone(),
|
||||||
|
users_db: db.users.clone(),
|
||||||
|
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||||
|
db: db.clone(),
|
||||||
|
config,
|
||||||
|
analytics_queue: queue,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = create_router(state);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let url = format!("http://{}", addr);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(client, url, handle, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_ownership_isolation_endpoints() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_ownership_test_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let (client, base_url, _server_handle, db) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
// Create User A
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("usera".to_string()),
|
||||||
|
Some("password123".to_string()),
|
||||||
|
None,
|
||||||
|
create_temp_config(temp_dir.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create User B
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("userb".to_string()),
|
||||||
|
Some("password123".to_string()),
|
||||||
|
None,
|
||||||
|
create_temp_config(temp_dir.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (id_a, _id_b) = {
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
let a = bzod::db::users::get_user_by_username(&conn, "usera")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let b = bzod::db::users::get_user_by_username(&conn, "userb")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
(a, b)
|
||||||
|
};
|
||||||
|
|
||||||
|
// User A adds a URL
|
||||||
|
let urla_id = {
|
||||||
|
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||||
|
let url = bzod::db::content::create_url_extended(
|
||||||
|
&conn_a,
|
||||||
|
"!usera-slug",
|
||||||
|
"https://usera.com",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"!usera-slug",
|
||||||
|
id_a,
|
||||||
|
"url",
|
||||||
|
&url.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
url.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// User A adds a Landing Page
|
||||||
|
let pagea_id = {
|
||||||
|
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||||
|
let page = bzod::db::content::create_landing_page(
|
||||||
|
&conn_a,
|
||||||
|
"!usera-page",
|
||||||
|
"usera-page",
|
||||||
|
"Title A",
|
||||||
|
"<html></html>",
|
||||||
|
"published",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"!usera-page",
|
||||||
|
id_a,
|
||||||
|
"page",
|
||||||
|
&page.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
page.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Log in as User B
|
||||||
|
let login_url = format!("{}/login", base_url);
|
||||||
|
let res = client.get(&login_url).send().await.unwrap();
|
||||||
|
let html = res.text().await.unwrap();
|
||||||
|
let csrf_token = extract_csrf_token(&html).unwrap();
|
||||||
|
|
||||||
|
let mut login_params = HashMap::new();
|
||||||
|
login_params.insert("username", "userb");
|
||||||
|
login_params.insert("password", "password123");
|
||||||
|
login_params.insert("csrf_token", &csrf_token);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.post(&login_url)
|
||||||
|
.form(&login_params)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); // Redirects after login
|
||||||
|
|
||||||
|
// 2. User B tries to view User A's URL analytics -> 403 Forbidden
|
||||||
|
let url_analytics_url = format!("{}/user/analytics/url/{}", base_url, urla_id);
|
||||||
|
let res = client.get(&url_analytics_url).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// 3. User B tries to view User A's Page analytics -> 403 Forbidden
|
||||||
|
let page_analytics_url = format!("{}/user/analytics/page/{}", base_url, pagea_id);
|
||||||
|
let res = client.get(&page_analytics_url).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// 4. User B tries to export CSV of User A's URL analytics -> 403 Forbidden
|
||||||
|
let csv_export_url = format!("{}/user/analytics/url/{}/export/csv", base_url, urla_id);
|
||||||
|
let res = client.get(&csv_export_url).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// 5. User B tries to export JSON of User A's URL analytics -> 403 Forbidden
|
||||||
|
let json_export_url = format!("{}/user/analytics/url/{}/export/json", base_url, urla_id);
|
||||||
|
let res = client.get(&json_export_url).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// 6. User B tries to export CSV of User A's Page analytics -> 403 Forbidden
|
||||||
|
let csv_page_export_url = format!("{}/user/analytics/page/{}/export/csv", base_url, pagea_id);
|
||||||
|
let res = client.get(&csv_page_export_url).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// 7. User B tries to export JSON of User A's Page analytics -> 403 Forbidden
|
||||||
|
let json_page_export_url = format!("{}/user/analytics/page/{}/export/json", base_url, pagea_id);
|
||||||
|
let res = client.get(&json_page_export_url).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use bzod::analytics::AnalyticsQueue;
|
||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use bzod::state::AppState;
|
||||||
|
use bzod::web::create_router;
|
||||||
|
|
||||||
|
fn compute_sha256(value: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.admin_username = "admin".to_string();
|
||||||
|
config.base_url = Some("http://localhost:8080".to_string());
|
||||||
|
config.cookie_secure = false;
|
||||||
|
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_test_server(
|
||||||
|
temp_dir: PathBuf,
|
||||||
|
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
||||||
|
let config = create_temp_config(temp_dir);
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let queue = AnalyticsQueue::new(db.clone(), 100);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
admin_db: db.admin.clone(),
|
||||||
|
content_db: db.content.clone(),
|
||||||
|
analytics_db: db.analytics.clone(),
|
||||||
|
system_db: db.system.clone(),
|
||||||
|
users_db: db.users.clone(),
|
||||||
|
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||||
|
db: db.clone(),
|
||||||
|
config,
|
||||||
|
analytics_queue: queue,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = create_router(state);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let url = format!("http://{}", addr);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(client, url, handle, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_qr_endpoints_and_redirection_hardening() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_qr_test_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let (client, base_url, _server_handle, db) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
// Create User A
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("usera".to_string()),
|
||||||
|
Some("password123".to_string()),
|
||||||
|
None,
|
||||||
|
create_temp_config(temp_dir.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let id_a = {
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// User A adds an active URL
|
||||||
|
{
|
||||||
|
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||||
|
let url = bzod::db::content::create_url_extended(
|
||||||
|
&conn_a,
|
||||||
|
"a1b2c3",
|
||||||
|
"https://active.com",
|
||||||
|
Some("active-url-title"),
|
||||||
|
None,
|
||||||
|
&vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"a1b2c3",
|
||||||
|
id_a,
|
||||||
|
"url",
|
||||||
|
&url.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// User A adds a disabled URL
|
||||||
|
{
|
||||||
|
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||||
|
let url = bzod::db::content::create_url_extended(
|
||||||
|
&conn_a,
|
||||||
|
"d4e5f6",
|
||||||
|
"https://disabled.com",
|
||||||
|
Some("disabled-url-title"),
|
||||||
|
None,
|
||||||
|
&vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"d4e5f6",
|
||||||
|
id_a,
|
||||||
|
"url",
|
||||||
|
&url.id,
|
||||||
|
"disabled",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// User A adds an active Page
|
||||||
|
{
|
||||||
|
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||||
|
let page = bzod::db::content::create_landing_page(
|
||||||
|
&conn_a,
|
||||||
|
"a1b2",
|
||||||
|
"active-page",
|
||||||
|
"Active Page",
|
||||||
|
"<html></html>",
|
||||||
|
"published",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"a1b2",
|
||||||
|
id_a,
|
||||||
|
"page",
|
||||||
|
&page.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// User A adds a disabled Page
|
||||||
|
{
|
||||||
|
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||||
|
let page = bzod::db::content::create_landing_page(
|
||||||
|
&conn_a,
|
||||||
|
"c3d4",
|
||||||
|
"disabled-page",
|
||||||
|
"Disabled Page",
|
||||||
|
"<html></html>",
|
||||||
|
"draft",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"c3d4",
|
||||||
|
id_a,
|
||||||
|
"page",
|
||||||
|
&page.id,
|
||||||
|
"disabled",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Verify Active URL QR endpoints return 200 with correct content types
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/a1b2c3.png", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers().get("content-type").unwrap().to_str().unwrap(),
|
||||||
|
"image/png"
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/a1b2c3.svg", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers().get("content-type").unwrap().to_str().unwrap(),
|
||||||
|
"image/svg+xml"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Verify Disabled URL redirect returns 410 Gone
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/a1b2c3", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY); // Redirects to destination
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/d4e5f6", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
// 3. Verify Disabled URL QR endpoints return 410 Gone
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/d4e5f6.png", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/d4e5f6.svg", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
// 4. Verify Active Page QR endpoints return 200
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/a1b2.png", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/a1b2.svg", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
|
||||||
|
// 5. Verify Disabled Page redirection returns 410 Gone
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/p/c3d4", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
// 6. Verify Disabled Page QR endpoints return 410 Gone
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/c3d4.png", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/c3d4.svg", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
// 7. Verify Missing URL/Page QR endpoints return 404 Not Found
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}/api/qr/missing-slug.png", base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ async fn test_global_slug_lookup_and_redirection() {
|
|||||||
user_id,
|
user_id,
|
||||||
"url",
|
"url",
|
||||||
"xyz",
|
"xyz",
|
||||||
|
"active",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -173,6 +174,7 @@ async fn test_disabled_slug_returns_410() {
|
|||||||
user_id,
|
user_id,
|
||||||
"url",
|
"url",
|
||||||
"xyz",
|
"xyz",
|
||||||
|
"active",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ async fn test_global_slug_uniqueness() {
|
|||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
// Register a slug
|
// Register a slug
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1").unwrap();
|
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1", "active")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Verify it is not available
|
// Verify it is not available
|
||||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap();
|
let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap();
|
||||||
@@ -113,6 +114,7 @@ async fn test_slug_release_on_user_deletion() {
|
|||||||
user_id,
|
user_id,
|
||||||
"url",
|
"url",
|
||||||
"url_xyz",
|
"url_xyz",
|
||||||
|
"active",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
|
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.base_url = Some("http://bzo.in".to_string());
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_admin_url_slug_collision_rejected() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_1_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "url", "url1", "active")
|
||||||
|
.unwrap();
|
||||||
|
let res = bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"collision",
|
||||||
|
1,
|
||||||
|
"url",
|
||||||
|
"url2",
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_admin_page_slug_collision_rejected() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_2_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "page", "page1", "active")
|
||||||
|
.unwrap();
|
||||||
|
let res = bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"collision",
|
||||||
|
1,
|
||||||
|
"page",
|
||||||
|
"page2",
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_url_slug_collision_rejected() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_3_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "collision", 2, "url", "url1", "active")
|
||||||
|
.unwrap();
|
||||||
|
let res = bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"collision",
|
||||||
|
2,
|
||||||
|
"url",
|
||||||
|
"url2",
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_page_slug_collision_rejected() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_4_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "collision", 2, "page", "page1", "active")
|
||||||
|
.unwrap();
|
||||||
|
let res = bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"collision",
|
||||||
|
2,
|
||||||
|
"page",
|
||||||
|
"page2",
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_url_page_slug_collision_rejected() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_5_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "url", "url1", "active")
|
||||||
|
.unwrap();
|
||||||
|
let res = bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"collision",
|
||||||
|
1,
|
||||||
|
"page",
|
||||||
|
"page1",
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_page_url_slug_collision_rejected() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_6_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "page", "page1", "active")
|
||||||
|
.unwrap();
|
||||||
|
let res = bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"collision",
|
||||||
|
1,
|
||||||
|
"url",
|
||||||
|
"url1",
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_slug_reusable_after_delete() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_7_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
|
||||||
|
.unwrap();
|
||||||
|
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
|
||||||
|
|
||||||
|
bzod::db::users::release_global_slug(&system_conn, "slug", 1).unwrap();
|
||||||
|
assert!(bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "page", "page1", "active")
|
||||||
|
.unwrap();
|
||||||
|
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_disabled_slug_still_blocks_registration() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_8_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "disabled")
|
||||||
|
.unwrap();
|
||||||
|
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
|
||||||
|
|
||||||
|
let res =
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "slug", 2, "page", "page1", "active");
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_transferred_slug_remains_unique() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_9_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
|
||||||
|
.unwrap();
|
||||||
|
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
|
||||||
|
|
||||||
|
// Transfer slug (by setting owner_user_id to 2)
|
||||||
|
system_conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE global_slugs SET owner_user_id = 2 WHERE slug = 'slug'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let res =
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url2", "active");
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_transfer_preserves_global_slug_record() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_10_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify it exists in global_slugs
|
||||||
|
let owner_id: i64 = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT owner_user_id FROM global_slugs WHERE slug = 'slug'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(owner_id, 1);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
@@ -72,8 +72,15 @@ async fn test_slug_transfer() {
|
|||||||
// Register globally
|
// Register globally
|
||||||
{
|
{
|
||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!trans-slug", id_a, "url", &url.id)
|
bzod::db::users::register_global_slug(
|
||||||
.unwrap();
|
&system_conn,
|
||||||
|
"!trans-slug",
|
||||||
|
id_a,
|
||||||
|
"url",
|
||||||
|
&url.id,
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let users_conn = db.users.lock().unwrap();
|
let users_conn = db.users.lock().unwrap();
|
||||||
bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap();
|
bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap();
|
||||||
|
|||||||
@@ -22,8 +22,15 @@ async fn test_soft_delete_reserves_slug() {
|
|||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
// Register slug
|
// Register slug
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!slug-to-delete", 10, "url", "url_123")
|
bzod::db::users::register_global_slug(
|
||||||
.unwrap();
|
&system_conn,
|
||||||
|
"!slug-to-delete",
|
||||||
|
10,
|
||||||
|
"url",
|
||||||
|
"url_123",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Soft delete slug
|
// Soft delete slug
|
||||||
bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap();
|
bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap();
|
||||||
@@ -39,7 +46,7 @@ async fn test_soft_delete_reserves_slug() {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(status, "soft_deleted");
|
assert_eq!(status, "disabled");
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
}
|
}
|
||||||
@@ -55,8 +62,15 @@ async fn test_permanent_delete_releases_slug() {
|
|||||||
let system_conn = db.system.lock().unwrap();
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
// Register slug
|
// Register slug
|
||||||
bzod::db::users::register_global_slug(&system_conn, "!slug-to-purge", 10, "url", "url_456")
|
bzod::db::users::register_global_slug(
|
||||||
.unwrap();
|
&system_conn,
|
||||||
|
"!slug-to-purge",
|
||||||
|
10,
|
||||||
|
"url",
|
||||||
|
"url_456",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Release global slug (permanent deletion)
|
// Release global slug (permanent deletion)
|
||||||
bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap();
|
bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.base_url = Some("http://bzo.in".to_string());
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cleanup_stale_reservations() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_tx_test_1_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
// Insert a reserving slug older than 15 minutes (e.g. 20 minutes ago)
|
||||||
|
let old_time = (chrono::Utc::now() - chrono::Duration::minutes(20)).to_rfc3339();
|
||||||
|
system_conn.execute(
|
||||||
|
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||||
|
VALUES ('stale-slug', 2, 'url', '', ?1, ?1, 'reserving')",
|
||||||
|
[&old_time]
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// Verify it exists before cleanup
|
||||||
|
let exists: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists);
|
||||||
|
|
||||||
|
// Run cleanup
|
||||||
|
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
|
||||||
|
assert_eq!(cleaned, 1);
|
||||||
|
|
||||||
|
// Verify it is gone
|
||||||
|
let exists: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!exists);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cleanup_preserves_valid_reservations() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_tx_test_2_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
|
||||||
|
// Insert a reserving slug that is brand new (e.g. 1 minute ago)
|
||||||
|
let new_time = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
|
||||||
|
system_conn.execute(
|
||||||
|
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||||
|
VALUES ('fresh-slug', 2, 'url', '', ?1, ?1, 'reserving')",
|
||||||
|
[&new_time]
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// Run cleanup
|
||||||
|
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
|
||||||
|
assert_eq!(cleaned, 0);
|
||||||
|
|
||||||
|
// Verify it is still there
|
||||||
|
let exists: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'fresh-slug')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user