Release v0.6.0
This commit is contained in:
1 parent
7069ca9db7
commit
38fc7123cf
75 files changed
+11199
-7508
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/target
|
/target/
|
||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|||||||
Generated
+1
-1
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
version = "0.5.3"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
||||||
version = "0.5.3"
|
version = "0.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/thakares/nx9-url-shortener"
|
repository = "https://github.com/thakares/nx9-url-shortener"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
[](https://github.com/thakares/bzod)
|
[](https://github.com/thakares/bzod)
|
||||||
[](https://codeberg.org/thakares/bzod)
|
[](https://codeberg.org/thakares/bzod)
|
||||||
@@ -90,7 +90,7 @@ No recurring subscription fees.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Runtime Efficiency (v0.5.3)
|
## Runtime Efficiency (v0.6.0)
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---------|------:|
|
|---------|------:|
|
||||||
@@ -945,6 +945,33 @@ src/
|
|||||||
├── templates/
|
├── templates/
|
||||||
├── utils/
|
├── utils/
|
||||||
├── web/
|
├── web/
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── analytics.rs
|
||||||
|
│ │ ├── api_keys.rs
|
||||||
|
│ │ ├── audit.rs
|
||||||
|
│ │ ├── auth.rs
|
||||||
|
│ │ ├── backups.rs
|
||||||
|
│ │ ├── dashboard.rs
|
||||||
|
│ │ ├── health.rs
|
||||||
|
│ │ ├── moderation.rs
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── pages.rs
|
||||||
|
│ │ ├── quotas.rs
|
||||||
|
│ │ ├── sessions.rs
|
||||||
|
│ │ ├── settings.rs
|
||||||
|
│ │ ├── urls.rs
|
||||||
|
│ │ └── users.rs
|
||||||
|
│ ├── api.rs
|
||||||
|
│ ├── bulk.rs
|
||||||
|
│ ├── middleware.rs
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── multi_user.rs
|
||||||
|
│ ├── pages.rs
|
||||||
|
│ ├── password_gate.rs
|
||||||
|
│ ├── qr.rs
|
||||||
|
│ ├── redirect.rs
|
||||||
|
│ ├── routes.rs
|
||||||
|
│ └── system.rs
|
||||||
|
|
||||||
templates/
|
templates/
|
||||||
|
|
||||||
@@ -1108,6 +1135,8 @@ Highlights include:
|
|||||||
- Upgrade Validation
|
- Upgrade Validation
|
||||||
- Backup Manifest
|
- Backup Manifest
|
||||||
- Transaction-safe Maintenance
|
- Transaction-safe Maintenance
|
||||||
|
- Modular Admin Architecture (v0.6.0)
|
||||||
|
- Redirect Handler Hardening (v0.6.0)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
BZOD_VERSION="0.6.0"
|
||||||
|
|
||||||
SERVICE_USER="bzod"
|
SERVICE_USER="bzod"
|
||||||
INSTALL_PATH="/usr/local/bin/bzod"
|
INSTALL_PATH="/usr/local/bin/bzod"
|
||||||
CONFIG_DIR="/etc/bzod"
|
CONFIG_DIR="/etc/bzod"
|
||||||
@@ -48,7 +50,7 @@ case $ARCH in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
REPO="thakares/nx9-url-shortener"
|
REPO="thakares/nx9-url-shortener"
|
||||||
RELEASE_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}"
|
RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${BINARY_NAME}"
|
||||||
|
|
||||||
TMP_BINARY=$(mktemp)
|
TMP_BINARY=$(mktemp)
|
||||||
|
|
||||||
@@ -93,13 +95,24 @@ if [ ! -x "${INSTALL_PATH}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || {
|
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || {
|
||||||
echo -e "${RED}Binary verification failed${NC}"
|
echo -e "${RED}Binary verification failed${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show installed version
|
# Verify -V also works
|
||||||
|
"${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || {
|
||||||
|
echo -e "${RED}Binary -V verification failed${NC}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show installed version and verify it matches requested version
|
||||||
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
||||||
|
EXPECTED_VERSION="bzod ${BZOD_VERSION}"
|
||||||
|
if [ "${VERSION}" != "${EXPECTED_VERSION}" ]; then
|
||||||
|
echo -e "${RED}Version mismatch: expected '${EXPECTED_VERSION}', got '${VERSION}'${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
|
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
|
||||||
|
|
||||||
# 3. Create System User
|
# 3. Create System User
|
||||||
@@ -175,11 +188,23 @@ systemctl daemon-reload
|
|||||||
# 7. Initialize & Start
|
# 7. Initialize & Start
|
||||||
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
|
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
|
||||||
|
|
||||||
if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then
|
# Database creation and migration is handled automatically by 'bzod serve'
|
||||||
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
|
if [ -f "${DATA_DIR}/admin/admin.db" ] || [ -f "${DATA_DIR}/admin.db" ]; then
|
||||||
echo -e "${GREEN}✓ Databases initialized${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
|
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
|
||||||
|
|
||||||
|
# Pre-upgrade: stop service and backup databases
|
||||||
|
if systemctl is-active --quiet bzod 2>/dev/null; then
|
||||||
|
echo -e "${BLUE} Stopping BZOD for safe database backup...${NC}"
|
||||||
|
systemctl stop bzod
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_DIR="/var/lib/bzod/pre-upgrade-backup-v${BZOD_VERSION}"
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data" 2>/dev/null || true
|
||||||
|
cp "${ENV_FILE}" "${BACKUP_DIR}/bzod.env" 2>/dev/null || true
|
||||||
|
echo -e "${GREEN} ✓ Pre-upgrade backup created at ${BACKUP_DIR}${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Fresh installation (databases will be created on first start)${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
systemctl enable --now bzod
|
systemctl enable --now bzod
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ services:
|
|||||||
- PORT=8654
|
- PORT=8654
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
hostname: bzod
|
hostname: bzod
|
||||||
image: nx9-url-shortener:v0.5.3
|
image: nx9-url-shortener:v0.6.0
|
||||||
ports:
|
ports:
|
||||||
- mode: ingress
|
- mode: ingress
|
||||||
target: 8654
|
target: 8654
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Administrator Guide
|
# BZOD Administrator Guide
|
||||||
|
|
||||||
Version: v0.5.3
|
Version: v0.6.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+22
-5
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Architecture Guide
|
# BZOD Architecture Guide
|
||||||
|
|
||||||
Version: v0.5.3
|
Version: v0.6.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,7 +89,22 @@ Responsible for:
|
|||||||
Major modules:
|
Major modules:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
admin.rs
|
admin/ (modular feature directory)
|
||||||
|
auth.rs (authentication and session handling)
|
||||||
|
dashboard.rs (dashboard rendering)
|
||||||
|
urls.rs (URL management handlers)
|
||||||
|
pages.rs (landing page management handlers)
|
||||||
|
analytics.rs (analytics and export handlers)
|
||||||
|
settings.rs (settings and configuration handlers)
|
||||||
|
users.rs (user management handlers)
|
||||||
|
sessions.rs (session administration)
|
||||||
|
quotas.rs (quota management)
|
||||||
|
health.rs (health diagnostics)
|
||||||
|
backups.rs (backup and restore handlers)
|
||||||
|
api_keys.rs (API key management)
|
||||||
|
audit.rs (audit log handlers)
|
||||||
|
moderation.rs (content moderation handlers)
|
||||||
|
mod.rs (module exports and shared helpers)
|
||||||
api.rs
|
api.rs
|
||||||
pages.rs
|
pages.rs
|
||||||
redirect.rs
|
redirect.rs
|
||||||
@@ -339,9 +354,11 @@ Locate owner database
|
|||||||
↓
|
↓
|
||||||
Resolve URL
|
Resolve URL
|
||||||
↓
|
↓
|
||||||
|
Validate destination
|
||||||
|
↓
|
||||||
Record analytics
|
Record analytics
|
||||||
↓
|
↓
|
||||||
302 Redirect
|
301 Redirect (with safe Location header construction)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -590,7 +607,7 @@ Coverage includes:
|
|||||||
* Upgrade validation
|
* Upgrade validation
|
||||||
* Multi-user isolation
|
* Multi-user isolation
|
||||||
|
|
||||||
v0.5.0 includes more than 90 automated tests.
|
The project includes comprehensive automated test coverage spanning unit, integration, security, and end-to-end tests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -635,7 +652,7 @@ Planned for future releases:
|
|||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
|
|
||||||
BZOD v0.5.0 is built around a simple principle:
|
BZOD is built around a simple principle:
|
||||||
|
|
||||||
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
|
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backup & Restore Guide
|
# Backup & Restore Guide
|
||||||
|
|
||||||
Version: v0.5.3
|
Version: v0.6.0
|
||||||
Applies To: BZOD Multi-User Platform
|
Applies To: BZOD Multi-User Platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -6,6 +6,77 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||||
|
|
||||||
|
- **Legacy Backup Restore**: Full backward-compatible restore support for `legacy_flat_backup` archives into the current multi-tenant database architecture
|
||||||
|
- **CLI Version Reporting**: Added `--version` / `-V` flags derived from Cargo package metadata
|
||||||
|
- **Deploy Script**: Removed obsolete `init-db` command; database creation and migration now handled by `bzod serve`
|
||||||
|
- **Version Verification**: Deploy script now verifies installed binary version matches requested version
|
||||||
|
|
||||||
|
# v0.5.3 — Architecture Refinement & Redirect Hardening
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
* Eliminated the monolithic `admin.rs` handler file
|
||||||
|
* Reorganized admin functionality into focused feature modules under `src/web/admin/`
|
||||||
|
* Separated authentication, dashboard, URLs, pages, analytics, settings, users, sessions, quotas, health, backups, API keys, audit, and moderation into dedicated modules
|
||||||
|
* Extracted shared authentication and authorization helpers
|
||||||
|
* Extracted common export and helper functionality
|
||||||
|
|
||||||
|
### Redirect Handling
|
||||||
|
|
||||||
|
* Removed panic-prone `HeaderValue::from_str(...).unwrap()` pattern from the redirect path
|
||||||
|
* Added destination URL validation (scheme validation, control character rejection)
|
||||||
|
* Added safe HTTP Location header construction
|
||||||
|
* Improved database error logging with structured fields
|
||||||
|
* Reduced unnecessary database mutex lock acquisitions on the redirect hot path
|
||||||
|
* Removed synchronous expiration writes from the redirect hot path
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improved
|
||||||
|
|
||||||
|
* Database lock scoping across admin handlers
|
||||||
|
* Error handling consistency and observability
|
||||||
|
* Handler decomposition for oversized functions
|
||||||
|
* Reduced duplicated handler logic across admin operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified
|
||||||
|
|
||||||
|
* Root landing page (GET /) confirmed as intentional route serving www/index.html
|
||||||
|
* Release binary built successfully
|
||||||
|
* Runtime smoke tests passed (GET /, GET /login, GET /admin/login all return HTTP 200)
|
||||||
|
* SQLite WAL mode and foreign-key enforcement initialized successfully
|
||||||
|
* All existing migrations reported as up to date
|
||||||
|
* Comprehensive automated test suite passed, including:
|
||||||
|
* Authentication and migration tests
|
||||||
|
* Redirect security tests
|
||||||
|
* Root landing page test
|
||||||
|
* Backup and restore tests
|
||||||
|
* Business workflow tests
|
||||||
|
* Security tests
|
||||||
|
* Slug namespace, registry, and transfer tests
|
||||||
|
* User management and isolation tests
|
||||||
|
* WAL recovery tests
|
||||||
|
* HTTP end-to-end tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
* This release is an internal architecture and quality improvement
|
||||||
|
* No new user-facing features were introduced
|
||||||
|
* Existing API and route behavior was preserved
|
||||||
|
* Existing redirect security and tenant isolation behavior was preserved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v0.5.1 - General Availability (GA)
|
# v0.5.1 - General Availability (GA)
|
||||||
|
|
||||||
Release Date: 2026-06-20
|
Release Date: 2026-06-20
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
|
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
|
||||||
|
|
||||||
The current command list for BZOD v0.5.3 is:
|
The current command list for BZOD v0.6.0 is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
$ bzod --help
|
$ bzod --help
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# BZOD v0.5.1 vs Self-Hosted URL Management Platforms
|
# BZOD v0.6.0 vs Self-Hosted URL Management Platforms
|
||||||
|
|
||||||
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
|
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# BZOD Database Architecture
|
# BZOD Database Architecture
|
||||||
|
|
||||||
BZOD v0.5.1 uses SQLite exclusively.
|
BZOD v0.6.0 uses SQLite exclusively.
|
||||||
|
|
||||||
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
|
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Installation Guide
|
# BZOD Installation Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.6.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,13 +183,13 @@ sudo pacman -S \
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://example.com/bzod-v0.5.0-linux-amd64.tar.gz
|
wget https://example.com/bzod-v0.6.0-linux-amd64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
Extract:
|
Extract:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf bzod-v0.5.0-linux-amd64.tar.gz
|
tar -xzf bzod-v0.6.0-linux-amd64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
Install:
|
Install:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Multi-User Architecture Guide
|
# BZOD Multi-User Architecture Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.6.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,132 @@
|
|||||||
|
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||||
|
|
||||||
|
Release Date: 2026-08-09
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Legacy Backup Restore Compatibility**: Backups created with the web admin "Download Backup" feature (`legacy_flat_backup` format) can now be correctly restored into the current multi-tenant database architecture. Previously, these restores failed with "no such table: users" because the restore validator ran against the empty legacy `users.db` before layout normalization.
|
||||||
|
|
||||||
|
- **CLI Version Reporting**: `bzod --version` and `bzod -V` now report the application version derived from Cargo.toml package metadata, ensuring the reported version cannot diverge from the build.
|
||||||
|
|
||||||
|
- **Deploy Script Modernization**: Removed the obsolete `init-db` command from the deployment script. Database creation and schema migration are now handled automatically by `bzod serve`. The deploy script now verifies the installed binary version using `--version`.
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
# BZOD v0.5.3 — Architecture Refinement & Redirect Hardening
|
||||||
|
|
||||||
|
BZOD v0.5.3 is an internal quality and maintainability release focused on architectural refinement, redirect handler hardening, and comprehensive verification.
|
||||||
|
|
||||||
|
No new user-facing features are introduced. Existing API contracts, route behavior, authentication, and tenant isolation are fully preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Highlights
|
||||||
|
|
||||||
|
## Modular Admin Architecture
|
||||||
|
|
||||||
|
The former monolithic admin handler file was eliminated and replaced with a focused module directory at `src/web/admin/`.
|
||||||
|
|
||||||
|
Feature modules:
|
||||||
|
|
||||||
|
* `auth.rs` — authentication and session handling
|
||||||
|
* `dashboard.rs` — dashboard rendering
|
||||||
|
* `urls.rs` — URL management handlers
|
||||||
|
* `pages.rs` — landing page management handlers
|
||||||
|
* `analytics.rs` — analytics and export handlers
|
||||||
|
* `settings.rs` — settings and configuration handlers
|
||||||
|
* `users.rs` — user management handlers
|
||||||
|
* `sessions.rs` — session administration
|
||||||
|
* `quotas.rs` — quota management
|
||||||
|
* `health.rs` — health diagnostics
|
||||||
|
* `backups.rs` — backup and restore handlers
|
||||||
|
* `api_keys.rs` — API key management
|
||||||
|
* `audit.rs` — audit log handlers
|
||||||
|
* `moderation.rs` — content moderation handlers
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
* Improved code organization and navigability
|
||||||
|
* Reduced coupling between feature areas
|
||||||
|
* Improved database lock scoping
|
||||||
|
* Reduced duplicated handler logic
|
||||||
|
* Better error handling consistency and observability
|
||||||
|
* Simplified future extension
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redirect Handler Hardening
|
||||||
|
|
||||||
|
The public redirect path (`GET /:code`) was hardened against invalid HTTP Location header values.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
* Removed the panic-prone `HeaderValue::from_str(...).unwrap()` pattern
|
||||||
|
* Added destination URL validation (scheme enforcement, control character rejection)
|
||||||
|
* Added safe Location header construction that handles malformed values gracefully
|
||||||
|
* Improved database error logging with structured fields
|
||||||
|
* Reduced unnecessary database mutex lock acquisitions
|
||||||
|
* Removed synchronous expiration writes from the redirect hot path
|
||||||
|
|
||||||
|
Existing redirect security and tenant isolation behavior was preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Landing Page Verification
|
||||||
|
|
||||||
|
* Confirmed `GET /` as an intentional application route serving `www/index.html`
|
||||||
|
* Resolved a runtime path-resolution issue affecting static landing-page resolution
|
||||||
|
* Verified `GET /` returns HTTP 200
|
||||||
|
* Verified `GET /login` returns HTTP 200
|
||||||
|
* Verified `GET /admin/login` returns HTTP 200
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing & Validation
|
||||||
|
|
||||||
|
BZOD v0.5.3 passed:
|
||||||
|
|
||||||
|
* Release build (`cargo build --release`)
|
||||||
|
* Comprehensive automated test suite, including:
|
||||||
|
* Authentication and migration tests
|
||||||
|
* Redirect security tests
|
||||||
|
* Root landing page test
|
||||||
|
* Backup and restore tests
|
||||||
|
* Business workflow tests
|
||||||
|
* Security tests
|
||||||
|
* Slug namespace, registry, and transfer tests
|
||||||
|
* User management and isolation tests
|
||||||
|
* WAL recovery tests
|
||||||
|
* HTTP end-to-end tests
|
||||||
|
* Runtime smoke tests against the release binary
|
||||||
|
* SQLite WAL mode and foreign-key enforcement initialization
|
||||||
|
* Database migration verification (all migrations up to date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Compatibility
|
||||||
|
|
||||||
|
* No breaking changes
|
||||||
|
* No API changes
|
||||||
|
* No route changes
|
||||||
|
* No database schema changes
|
||||||
|
* No configuration changes
|
||||||
|
* Direct upgrade from v0.5.1 with no migration required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Repository
|
||||||
|
|
||||||
|
* Clean source tree established
|
||||||
|
* Build artifacts, temporary reports, and IDE metadata removed
|
||||||
|
* Existing BZOD Git history preserved
|
||||||
|
* Refactoring baseline merged with existing history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening
|
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening
|
||||||
|
|
||||||
**Release Date:** 2026-06-20
|
**Release Date:** 2026-06-20
|
||||||
|
|||||||
+27
-4
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Security Guide
|
# BZOD Security Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.6.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a
|
|||||||
* Disaster recovery
|
* Disaster recovery
|
||||||
* Operational simplicity
|
* Operational simplicity
|
||||||
|
|
||||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.5.0.
|
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.6.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -432,6 +432,27 @@ for:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Redirect Security
|
||||||
|
|
||||||
|
The redirect handler validates destination URLs before constructing HTTP Location headers.
|
||||||
|
|
||||||
|
Protections include:
|
||||||
|
|
||||||
|
* URL scheme validation (only http and https destinations are permitted)
|
||||||
|
* Control character rejection
|
||||||
|
* CRLF injection prevention
|
||||||
|
* Safe Location header construction (no panics on malformed values)
|
||||||
|
|
||||||
|
Invalid redirect destinations return:
|
||||||
|
|
||||||
|
```http
|
||||||
|
500 Internal Server Error
|
||||||
|
```
|
||||||
|
|
||||||
|
with structured server-side logging. Full destination values are not exposed to clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Audit Logging
|
# Audit Logging
|
||||||
|
|
||||||
Security-sensitive actions are logged.
|
Security-sensitive actions are logged.
|
||||||
@@ -599,7 +620,7 @@ If compromise is suspected:
|
|||||||
|
|
||||||
# Security Testing
|
# Security Testing
|
||||||
|
|
||||||
BZOD v0.5.0 includes tests covering:
|
BZOD v0.6.0 includes tests covering:
|
||||||
|
|
||||||
* Authentication
|
* Authentication
|
||||||
* Authorization
|
* Authorization
|
||||||
@@ -610,6 +631,8 @@ BZOD v0.5.0 includes tests covering:
|
|||||||
* Upgrade migrations
|
* Upgrade migrations
|
||||||
* Backup integrity
|
* Backup integrity
|
||||||
* Disaster recovery
|
* Disaster recovery
|
||||||
|
* Redirect destination validation
|
||||||
|
* HTTP Location header safety
|
||||||
|
|
||||||
These tests are executed during CI and release validation.
|
These tests are executed during CI and release validation.
|
||||||
|
|
||||||
@@ -642,7 +665,7 @@ These may be addressed in future releases.
|
|||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
|
|
||||||
BZOD v0.5.0 provides:
|
BZOD v0.6.0 provides:
|
||||||
|
|
||||||
* Centralized authentication
|
* Centralized authentication
|
||||||
* Secure session management
|
* Secure session management
|
||||||
|
|||||||
@@ -325,6 +325,38 @@ and:
|
|||||||
|
|
||||||
for final landing page render.
|
for final landing page render.
|
||||||
|
|
||||||
|
Root landing page:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /
|
||||||
|
```
|
||||||
|
|
||||||
|
must serve the static landing page.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```http
|
||||||
|
200 OK
|
||||||
|
Content-Type: text/html
|
||||||
|
```
|
||||||
|
|
||||||
|
Redirect security:
|
||||||
|
|
||||||
|
Redirect destinations are validated against:
|
||||||
|
|
||||||
|
* Invalid URL schemes
|
||||||
|
* CRLF injection attempts
|
||||||
|
* Control character injection
|
||||||
|
* Malformed HTTP Location header values
|
||||||
|
|
||||||
|
Invalid destinations must return:
|
||||||
|
|
||||||
|
```http
|
||||||
|
500 Internal Server Error
|
||||||
|
```
|
||||||
|
|
||||||
|
and must not panic or produce malformed HTTP responses.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 12. Backup Validation
|
# 12. Backup Validation
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Upgrade Guide
|
# Upgrade Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.6.0
|
||||||
|
|
||||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
|
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ pub struct AnalyticsQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AnalyticsQueue {
|
impl AnalyticsQueue {
|
||||||
pub fn new(db: Db, capacity: usize) -> Self {
|
pub fn new(
|
||||||
|
db: Db,
|
||||||
|
capacity: usize,
|
||||||
|
shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) -> (Self, tokio::task::JoinHandle<()>) {
|
||||||
let (sender, receiver) = mpsc::channel(capacity);
|
let (sender, receiver) = mpsc::channel(capacity);
|
||||||
|
|
||||||
// Spawn background worker to batch-write records
|
// Spawn background worker to batch-write records
|
||||||
tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
super::worker::run_worker(db, receiver).await;
|
super::worker::run_worker(db, receiver, shutdown_rx).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Self { sender }
|
(Self { sender }, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to queue a visit. Non-blocking.
|
// Attempt to queue a visit. Non-blocking.
|
||||||
|
|||||||
+10
-1
@@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch;
|
|||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
use crate::models::VisitRecord;
|
use crate::models::VisitRecord;
|
||||||
|
|
||||||
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
pub async fn run_worker(
|
||||||
|
db: Db,
|
||||||
|
mut receiver: mpsc::Receiver<VisitRecord>,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
let mut batch = Vec::new();
|
let mut batch = Vec::new();
|
||||||
let batch_size = 50;
|
let batch_size = 50;
|
||||||
let flush_interval = Duration::from_secs(2);
|
let flush_interval = Duration::from_secs(2);
|
||||||
@@ -37,6 +41,11 @@ pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
|||||||
flush_batch(&db, &mut batch);
|
flush_batch(&db, &mut batch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Analytics worker flushing pending records");
|
||||||
|
flush_batch(&db, &mut batch);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::services::destination_audit::{audit_all_destinations, format_report};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// Read-only audit of all stored redirect destinations.
|
||||||
|
///
|
||||||
|
/// Does not rewrite, delete, or "repair" any records.
|
||||||
|
pub async fn run(
|
||||||
|
data_dir: Option<String>,
|
||||||
|
mut config: Config,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if let Some(d) = data_dir {
|
||||||
|
config.data_dir = PathBuf::from(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Starting read-only destination audit...");
|
||||||
|
let db = Db::init(&config)?;
|
||||||
|
let report = audit_all_destinations(&db)?;
|
||||||
|
print!("{}", format_report(&report));
|
||||||
|
|
||||||
|
if report.invalid > 0 {
|
||||||
|
// Non-zero exit so automation can detect findings without treating them as crashes.
|
||||||
|
Err(format!(
|
||||||
|
"destination audit found {} invalid stored URL(s)",
|
||||||
|
report.invalid
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
pub mod admin_migrate;
|
pub mod admin_migrate;
|
||||||
|
pub mod audit_destinations;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod backup_user;
|
pub mod backup_user;
|
||||||
pub mod create_admin;
|
pub mod create_admin;
|
||||||
@@ -23,6 +24,7 @@ pub mod validate;
|
|||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "bzod")]
|
#[command(name = "bzod")]
|
||||||
|
#[command(version)]
|
||||||
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -72,6 +74,11 @@ pub enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
data_dir: Option<String>,
|
data_dir: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Read-only audit of stored redirect destinations (schemes, control chars, malformed)
|
||||||
|
AuditDestinations {
|
||||||
|
#[arg(long)]
|
||||||
|
data_dir: Option<String>,
|
||||||
|
},
|
||||||
/// Create a new administrator user in the database
|
/// Create a new administrator user in the database
|
||||||
CreateAdmin {
|
CreateAdmin {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|||||||
+297
-25
@@ -1,21 +1,250 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::services::registry_validator::RegistryIssueType;
|
||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use tar::Archive;
|
use tar::Archive;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Read backup_manifest.json and return true if this is a legacy_flat_backup.
|
||||||
|
fn is_legacy_flat_backup(temp_dir: &Path) -> bool {
|
||||||
|
let manifest_path = temp_dir.join("backup_manifest.json");
|
||||||
|
if !manifest_path.exists() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match std::fs::read_to_string(&manifest_path) {
|
||||||
|
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
|
||||||
|
Ok(val) => val.get("type").and_then(|t| t.as_str()) == Some("legacy_flat_backup"),
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect if the unpacked archive is in flat layout (files at root, not in admin/ subdirectory).
|
||||||
|
fn is_flat_layout(temp_dir: &Path) -> bool {
|
||||||
|
temp_dir.join("admin.db").exists() && !temp_dir.join("admin").join("admin.db").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bootstrap users.db for a legacy backup where users.db is empty/unmigrated.
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Runs USERS_MIGRATIONS on users.db to create the required schema.
|
||||||
|
/// 2. Reads the actual administrator identity from admin.db (preserving
|
||||||
|
/// the original username and argon2id password hash — no manufacturing).
|
||||||
|
/// 3. Creates a legacy_admin system placeholder (id=1) for tenant ownership.
|
||||||
|
/// 4. Creates an admin account with the original credentials.
|
||||||
|
/// 5. Scans global_slugs for owner_user_ids and creates disabled placeholder
|
||||||
|
/// accounts for any missing tenants.
|
||||||
|
fn bootstrap_legacy_users_db(temp_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use crate::db::migrations::{run_migrations, USERS_MIGRATIONS};
|
||||||
|
|
||||||
|
let users_db_path = temp_dir.join("admin").join("users.db");
|
||||||
|
let admin_db_path = temp_dir.join("admin").join("admin.db");
|
||||||
|
let system_db_path = temp_dir.join("admin").join("system.db");
|
||||||
|
|
||||||
|
// Check if users.db already has the users table (i.e., not a legacy backup)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&users_db_path)?;
|
||||||
|
let has_users_table: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='users');",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_users_table {
|
||||||
|
info!("users.db already has users table; skipping legacy bootstrap");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Legacy users.db detected (empty/unmigrated). Bootstrapping current schema...");
|
||||||
|
|
||||||
|
// Step 1: Run migrations to create the users.db schema
|
||||||
|
let mut users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||||
|
crate::db::sqlite::enable_wal(&users_conn, "users")?;
|
||||||
|
crate::db::sqlite::enable_foreign_keys(&users_conn, "users")?;
|
||||||
|
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None)?;
|
||||||
|
|
||||||
|
// Step 2: Read the actual administrator identity from admin.db
|
||||||
|
let (admin_username, admin_password_hash) = {
|
||||||
|
let admin_conn = rusqlite::Connection::open(&admin_db_path)?;
|
||||||
|
|
||||||
|
// The legacy admin.db users table has schema:
|
||||||
|
// id TEXT PRIMARY KEY (UUID), username TEXT, password_hash TEXT, created_at TEXT
|
||||||
|
// Read the actual admin — typically the first (and often only) user.
|
||||||
|
let result: Result<(String, String), _> = admin_conn.query_row(
|
||||||
|
"SELECT username, password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((username, hash)) => {
|
||||||
|
info!(
|
||||||
|
"Preserved administrator identity from legacy admin.db: username='{}'",
|
||||||
|
username
|
||||||
|
);
|
||||||
|
(username, hash)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Failed to read administrator credentials from legacy admin.db: {}",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: Create legacy_admin system placeholder (id=1) for tenant content ownership
|
||||||
|
// This account owns the content.db/analytics.db from the flat backup (users/1/).
|
||||||
|
// It uses the original admin's password hash so no synthetic credentials are introduced.
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
users_conn.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||||
|
rusqlite::params![
|
||||||
|
1i64,
|
||||||
|
"legacy_admin",
|
||||||
|
&admin_password_hash,
|
||||||
|
"disabled",
|
||||||
|
&now,
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [1i64])?;
|
||||||
|
info!("Created legacy_admin system account (id=1) for tenant content ownership");
|
||||||
|
|
||||||
|
// Step 4: Create the actual admin account with original credentials
|
||||||
|
users_conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, status, created_at, account_type)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||||
|
rusqlite::params![
|
||||||
|
&admin_username,
|
||||||
|
&admin_password_hash,
|
||||||
|
"active",
|
||||||
|
&now,
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let admin_id = users_conn.last_insert_rowid();
|
||||||
|
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [admin_id])?;
|
||||||
|
info!(
|
||||||
|
"Created admin account '{}' (id={}) with original credentials",
|
||||||
|
admin_username, admin_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 5: Scan global_slugs for owner_user_ids and create placeholders for missing tenants
|
||||||
|
if system_db_path.exists() {
|
||||||
|
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||||
|
let has_global_slugs: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_global_slugs {
|
||||||
|
let mut stmt =
|
||||||
|
system_conn.prepare("SELECT DISTINCT owner_user_id FROM global_slugs;")?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let owner_id: i64 = row.get(0)?;
|
||||||
|
// Skip user 1 (legacy_admin) and the admin we just created
|
||||||
|
if owner_id == 1 || owner_id == admin_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this user already exists in users.db
|
||||||
|
let exists: bool = users_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||||
|
[owner_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
// Create a disabled placeholder so RegistryValidator can resolve ownership.
|
||||||
|
// The tenant's actual databases were not included in the flat backup.
|
||||||
|
let placeholder_name = format!("restored_user_{}", owner_id);
|
||||||
|
users_conn.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||||
|
rusqlite::params![
|
||||||
|
owner_id,
|
||||||
|
&placeholder_name,
|
||||||
|
&admin_password_hash,
|
||||||
|
"disabled",
|
||||||
|
&now,
|
||||||
|
"standard",
|
||||||
|
"Placeholder created during legacy_flat_backup restore. Original tenant databases were not included in the flat backup."
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [owner_id])?;
|
||||||
|
warn!(
|
||||||
|
"Created placeholder account for user_id={} (referenced in global_slugs but tenant databases not in backup)",
|
||||||
|
owner_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify registry issues into hard errors vs warnings for legacy restore.
|
||||||
|
///
|
||||||
|
/// Hard errors: DuplicateSlug, InvalidTargetType, InvalidStatus
|
||||||
|
/// Warnings: MissingDatabase, MissingTarget, MissingOwner, StaleReservation,
|
||||||
|
/// TenantAdminHasIsolatedContent
|
||||||
|
fn classify_registry_issues(
|
||||||
|
issues: &[crate::services::registry_validator::RegistryIssue],
|
||||||
|
is_legacy: bool,
|
||||||
|
) -> (
|
||||||
|
Vec<&crate::services::registry_validator::RegistryIssue>,
|
||||||
|
Vec<&crate::services::registry_validator::RegistryIssue>,
|
||||||
|
) {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
for issue in issues {
|
||||||
|
match issue.issue_type {
|
||||||
|
RegistryIssueType::DuplicateSlug
|
||||||
|
| RegistryIssueType::InvalidTargetType
|
||||||
|
| RegistryIssueType::InvalidStatus => {
|
||||||
|
errors.push(issue);
|
||||||
|
}
|
||||||
|
RegistryIssueType::MissingOwner if !is_legacy => {
|
||||||
|
errors.push(issue);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// For legacy restores: MissingDatabase, MissingTarget, MissingOwner,
|
||||||
|
// StaleReservation, TenantAdminHasIsolatedContent are warnings.
|
||||||
|
// These represent pre-existing inconsistencies in the backup data,
|
||||||
|
// not restore corruption.
|
||||||
|
warnings.push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(errors, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn perform_restore(
|
pub fn perform_restore(
|
||||||
file_path: &std::path::Path,
|
file_path: &Path,
|
||||||
data_dir: &std::path::Path,
|
data_dir: &Path,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// 1. Open the archive
|
// 1. Open and unpack the archive to a temporary directory
|
||||||
let f = File::open(file_path)?;
|
let f = File::open(file_path)?;
|
||||||
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. Unpack to temporary directory first
|
|
||||||
let temp_dir =
|
let temp_dir =
|
||||||
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
|
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&temp_dir)?;
|
std::fs::create_dir_all(&temp_dir)?;
|
||||||
@@ -25,7 +254,33 @@ pub fn perform_restore(
|
|||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Run validation on temp_dir
|
// 2. Detect backup format
|
||||||
|
let is_legacy = is_legacy_flat_backup(&temp_dir);
|
||||||
|
let needs_normalization = is_flat_layout(&temp_dir);
|
||||||
|
|
||||||
|
if is_legacy {
|
||||||
|
info!("Detected legacy_flat_backup format — using legacy-aware restore path");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Normalize flat layout into multi-tenant structure BEFORE any validation
|
||||||
|
if needs_normalization {
|
||||||
|
info!("Normalizing flat database layout into multi-tenant structure...");
|
||||||
|
if let Err(e) = crate::services::backup_layout::normalize_restored_layout(&temp_dir) {
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(format!("Failed to normalize legacy layout: {}", e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. For legacy backups: bootstrap the empty users.db with the current schema
|
||||||
|
// and populate it from admin.db credentials
|
||||||
|
if is_legacy {
|
||||||
|
if let Err(e) = bootstrap_legacy_users_db(&temp_dir) {
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(format!("Failed to bootstrap legacy users database: {}", e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Run validation on the normalized temp_dir
|
||||||
let mut temp_config = Config::load();
|
let mut temp_config = Config::load();
|
||||||
temp_config.data_dir = temp_dir.clone();
|
temp_config.data_dir = temp_dir.clone();
|
||||||
|
|
||||||
@@ -46,16 +301,8 @@ pub fn perform_restore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Registry integrity check
|
// Registry integrity check
|
||||||
let system_db_path = if temp_dir.join("admin/system.db").exists() {
|
let system_db_path = temp_dir.join("admin").join("system.db");
|
||||||
temp_dir.join("admin/system.db")
|
let users_db_path = temp_dir.join("admin").join("users.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() {
|
if system_db_path.exists() && users_db_path.exists() {
|
||||||
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||||
@@ -68,12 +315,37 @@ pub fn perform_restore(
|
|||||||
) {
|
) {
|
||||||
Ok(issues) => {
|
Ok(issues) => {
|
||||||
if !issues.is_empty() {
|
if !issues.is_empty() {
|
||||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
let (hard_errors, warnings) = classify_registry_issues(&issues, is_legacy);
|
||||||
return Err(format!(
|
|
||||||
"Registry integrity errors in backup: {} issues detected",
|
// Log all warnings
|
||||||
issues.len()
|
for w in &warnings {
|
||||||
)
|
warn!(
|
||||||
.into());
|
"Legacy restore warning: {:?} — {}",
|
||||||
|
w.issue_type, w.description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort only on hard errors
|
||||||
|
if !hard_errors.is_empty() {
|
||||||
|
let descriptions: Vec<String> = hard_errors
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("{:?}: {}", e.issue_type, e.description))
|
||||||
|
.collect();
|
||||||
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(format!(
|
||||||
|
"Registry integrity errors in backup ({} critical): {}",
|
||||||
|
hard_errors.len(),
|
||||||
|
descriptions.join("; ")
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !warnings.is_empty() {
|
||||||
|
info!(
|
||||||
|
"Registry validation completed with {} warnings (pre-existing backup inconsistencies)",
|
||||||
|
warnings.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -83,13 +355,13 @@ pub fn perform_restore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. If validation succeeds, copy temp_dir contents to data_dir
|
// 6. If validation succeeds, atomically replace data_dir contents
|
||||||
if data_dir.exists() {
|
if data_dir.exists() {
|
||||||
let _ = std::fs::remove_dir_all(data_dir);
|
let _ = std::fs::remove_dir_all(data_dir);
|
||||||
}
|
}
|
||||||
std::fs::create_dir_all(data_dir)?;
|
std::fs::create_dir_all(data_dir)?;
|
||||||
|
|
||||||
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
|
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
std::fs::create_dir_all(dst)?;
|
std::fs::create_dir_all(dst)?;
|
||||||
for entry in std::fs::read_dir(src)? {
|
for entry in std::fs::read_dir(src)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
|
|||||||
+96
-20
@@ -7,6 +7,30 @@ use std::path::PathBuf;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("failed to install Ctrl+C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("failed to install signal handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
@@ -29,39 +53,63 @@ pub async fn run(
|
|||||||
// Init DBs
|
// Init DBs
|
||||||
let db = Db::init(&config)?;
|
let db = Db::init(&config)?;
|
||||||
|
|
||||||
|
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||||
|
let mut join_handles = Vec::new();
|
||||||
|
|
||||||
// Init Queue
|
// Init Queue
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
let (queue, analytics_handle) = AnalyticsQueue::new(db.clone(), 1000, shutdown_rx.clone());
|
||||||
|
join_handles.push(("analytics_worker", analytics_handle));
|
||||||
|
|
||||||
// Spawn background tasks
|
// Spawn background tasks
|
||||||
let link_checker_db = db.clone();
|
let link_checker_db = db.clone();
|
||||||
let link_checker_interval = config.link_check_interval_mins;
|
let link_checker_interval = config.link_check_interval_mins;
|
||||||
tokio::spawn(async move {
|
let rx = shutdown_rx.clone();
|
||||||
crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await;
|
join_handles.push((
|
||||||
});
|
"link_checker",
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::jobs::run_link_checker(link_checker_db, link_checker_interval, rx).await;
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
let aggregator_db = db.clone();
|
let aggregator_db = db.clone();
|
||||||
let aggregator_interval = config.aggregation_interval_mins;
|
let aggregator_interval = config.aggregation_interval_mins;
|
||||||
tokio::spawn(async move {
|
let rx = shutdown_rx.clone();
|
||||||
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await;
|
join_handles.push((
|
||||||
});
|
"aggregator",
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::jobs::run_aggregator(aggregator_db, aggregator_interval, rx).await;
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
let retention_db = db.clone();
|
let retention_db = db.clone();
|
||||||
let retention_days = config.data_retention_days;
|
let retention_days = config.data_retention_days;
|
||||||
tokio::spawn(async move {
|
let rx = shutdown_rx.clone();
|
||||||
crate::jobs::run_retention_cleaner(retention_db, retention_days).await;
|
join_handles.push((
|
||||||
});
|
"retention_cleaner",
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::jobs::run_retention_cleaner(retention_db, retention_days, rx).await;
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
// Spawn optional backup scheduler
|
// Spawn optional backup scheduler
|
||||||
let backup_db = db.clone();
|
let backup_db = db.clone();
|
||||||
let backup_config = config.clone();
|
let backup_config = config.clone();
|
||||||
tokio::spawn(async move {
|
let rx = shutdown_rx.clone();
|
||||||
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
|
join_handles.push((
|
||||||
});
|
"backup_scheduler",
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config, rx).await;
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
let expiry_db = db.clone();
|
let expiry_db = db.clone();
|
||||||
tokio::spawn(async move {
|
let rx = shutdown_rx.clone();
|
||||||
crate::jobs::run_expiry_checker(expiry_db).await;
|
join_handles.push((
|
||||||
});
|
"expiry_checker",
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::jobs::run_expiry_checker(expiry_db, rx).await;
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
let reconcile_db = db.clone();
|
let reconcile_db = db.clone();
|
||||||
let reconcile_interval_hours = {
|
let reconcile_interval_hours = {
|
||||||
@@ -75,9 +123,13 @@ pub async fn run(
|
|||||||
.and_then(|val| val.parse::<u64>().ok())
|
.and_then(|val| val.parse::<u64>().ok())
|
||||||
.unwrap_or(24)
|
.unwrap_or(24)
|
||||||
};
|
};
|
||||||
tokio::spawn(async move {
|
let rx = shutdown_rx.clone();
|
||||||
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
|
join_handles.push((
|
||||||
});
|
"quota_reconciliation",
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours, rx).await;
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
@@ -98,7 +150,31 @@ pub async fn run(
|
|||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
|
||||||
info!("Listening for requests on http://{}", addr);
|
info!("Listening for requests on http://{}", addr);
|
||||||
axum::serve(listener, router).await?;
|
|
||||||
|
axum::serve(listener, router)
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
shutdown_signal().await;
|
||||||
|
info!("Shutdown signal received");
|
||||||
|
info!("Stopping HTTP server...");
|
||||||
|
let _ = shutdown_tx.send(true);
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("Stopping background workers...");
|
||||||
|
|
||||||
|
let timeout_duration = std::time::Duration::from_secs(10);
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout_duration;
|
||||||
|
|
||||||
|
for (name, handle) in join_handles {
|
||||||
|
match tokio::time::timeout_at(deadline, handle).await {
|
||||||
|
Ok(Ok(_)) => {}
|
||||||
|
Ok(Err(e)) => tracing::error!("Background task '{}' panicked: {:?}", name, e),
|
||||||
|
Err(_) => tracing::warn!("Background task did not terminate: {}", name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Background workers stopped");
|
||||||
|
info!("BZOD shutdown complete");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
+12
-2
@@ -5,9 +5,19 @@ use super::{log_job_end, log_job_start};
|
|||||||
use crate::analytics::aggregate_day;
|
use crate::analytics::aggregate_day;
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
|
||||||
pub async fn run_aggregator(db: Db, interval_mins: u64) {
|
pub async fn run_aggregator(
|
||||||
|
db: Db,
|
||||||
|
interval_mins: u64,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Analytics aggregator shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!("Running background analytics aggregator...");
|
info!("Running background analytics aggregator...");
|
||||||
|
|
||||||
let user_ids: Vec<i64> = {
|
let user_ids: Vec<i64> = {
|
||||||
|
|||||||
+12
-2
@@ -4,7 +4,11 @@ use crate::db::Db;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
pub async fn run_backup_scheduler(db: Db, config: Config) {
|
pub async fn run_backup_scheduler(
|
||||||
|
db: Db,
|
||||||
|
config: Config,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
if !config.backup_enabled {
|
if !config.backup_enabled {
|
||||||
info!("Background backup scheduler is disabled.");
|
info!("Background backup scheduler is disabled.");
|
||||||
return;
|
return;
|
||||||
@@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
|
|||||||
);
|
);
|
||||||
loop {
|
loop {
|
||||||
// Run backup every configured interval
|
// Run backup every configured interval
|
||||||
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Backup scheduler shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!("Running background database backup...");
|
info!("Running background database backup...");
|
||||||
|
|
||||||
let job_id = log_job_start(&db.system, "database_backup");
|
let job_id = log_job_start(&db.system, "database_backup");
|
||||||
|
|||||||
+49
-9
@@ -1,33 +1,73 @@
|
|||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::info;
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
/// Background job that marks expired URLs.
|
/// Background job that marks expired URLs.
|
||||||
///
|
///
|
||||||
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
|
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
|
||||||
/// gets flipped to `expired = 1`.
|
/// gets flipped to `expired = 1`.
|
||||||
pub async fn run_expiry_checker(db: Db) {
|
///
|
||||||
|
/// Correctness note: the redirect handler treats wall-clock `expires_at` as
|
||||||
|
/// authoritative and returns 410 without depending on this sweeper. The sweeper
|
||||||
|
/// is maintenance (persist `expired=1`) and must remain idempotent.
|
||||||
|
pub async fn run_expiry_checker(db: Db, mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Expiry checker shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let user_ids: Vec<i64> = {
|
let user_ids: Vec<i64> = {
|
||||||
let conn = db.users.lock().unwrap();
|
let conn = match db.users.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "expiry job: users_db mutex poisoned");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => continue,
|
Err(e) => {
|
||||||
|
error!(error = %e, "expiry job: failed to list users");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let rows = match stmt.query_map([], |row| row.get(0)) {
|
let rows = match stmt.query_map([], |row| row.get(0)) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => continue,
|
Err(e) => {
|
||||||
|
error!(error = %e, "expiry job: failed to map user ids");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
rows.filter_map(|r| r.ok()).collect()
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut total_expired = 0;
|
let mut total_expired = 0;
|
||||||
for user_id in user_ids {
|
for user_id in user_ids {
|
||||||
if let Ok(conn) = super::open_user_content_conn(&db, user_id) {
|
match super::open_user_content_conn(&db, user_id) {
|
||||||
let count = crate::db::content::expire_urls(&conn).unwrap_or(0);
|
Ok(conn) => match crate::db::content::expire_urls(&conn) {
|
||||||
total_expired += count;
|
Ok(count) => total_expired += count,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
owner_user_id = user_id,
|
||||||
|
error = %e,
|
||||||
|
"expiry job: expire_urls failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
// Missing content.db for a user is common; only log open errors that are unexpected.
|
||||||
|
if !matches!(e, rusqlite::Error::SqliteFailure(_, _)) {
|
||||||
|
warn!(
|
||||||
|
owner_user_id = user_id,
|
||||||
|
error = %e,
|
||||||
|
"expiry job: could not open content.db"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -8,7 +8,11 @@ use uuid::Uuid;
|
|||||||
use super::{log_job_end, log_job_start};
|
use super::{log_job_end, log_job_start};
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
|
||||||
pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
pub async fn run_link_checker(
|
||||||
|
db: Db,
|
||||||
|
interval_mins: u64,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.user_agent("bzod-link-checker/0.1")
|
.user_agent("bzod-link-checker/0.1")
|
||||||
@@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Sleep first to give server time to start up
|
// Sleep first to give server time to start up
|
||||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Link checker shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!("Running background link health check...");
|
info!("Running background link health check...");
|
||||||
|
|
||||||
let job_id = log_job_start(&db.system, "link_checker");
|
let job_id = log_job_start(&db.system, "link_checker");
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ use crate::db::Db;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
|
pub async fn run_quota_reconciliation(
|
||||||
|
db: Db,
|
||||||
|
interval_hours: u64,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
loop {
|
loop {
|
||||||
// Sleep first
|
// Sleep first
|
||||||
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(interval_hours * 3600)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Quota reconciliation shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!("Running background quota reconciliation...");
|
info!("Running background quota reconciliation...");
|
||||||
|
|
||||||
let user_ids: Vec<i64> = {
|
let user_ids: Vec<i64> = {
|
||||||
|
|||||||
+12
-2
@@ -4,7 +4,11 @@ use tracing::{error, info};
|
|||||||
use super::{log_job_end, log_job_start};
|
use super::{log_job_end, log_job_start};
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
|
||||||
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
pub async fn run_retention_cleaner(
|
||||||
|
db: Db,
|
||||||
|
retention_days_opt: Option<i64>,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
let retention_days = match retention_days_opt {
|
let retention_days = match retention_days_opt {
|
||||||
Some(days) => days,
|
Some(days) => days,
|
||||||
None => return,
|
None => return,
|
||||||
@@ -12,7 +16,13 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Check once every 24 hours
|
// Check once every 24 hours
|
||||||
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(24 * 3600)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Retention cleaner shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!("Running background data retention cleanup...");
|
info!("Running background data retention cleanup...");
|
||||||
|
|
||||||
let user_ids: Vec<i64> = {
|
let user_ids: Vec<i64> = {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Commands::Validate { data_dir } => {
|
Commands::Validate { data_dir } => {
|
||||||
bzod::cli::validate::run(data_dir, config).await?;
|
bzod::cli::validate::run(data_dir, config).await?;
|
||||||
}
|
}
|
||||||
|
Commands::AuditDestinations { data_dir } => {
|
||||||
|
bzod::cli::audit_destinations::run(data_dir, config).await?;
|
||||||
|
}
|
||||||
Commands::CreateAdmin { username, data_dir } => {
|
Commands::CreateAdmin { username, data_dir } => {
|
||||||
bzod::cli::create_admin::run(username, data_dir, config).await?;
|
bzod::cli::create_admin::run(username, data_dir, config).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! Post-restore filesystem layout normalization for multi-tenant BZOD data dirs.
|
||||||
|
//!
|
||||||
|
//! Extracted from admin restore handlers so path moves are testable without HTTP.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
|
||||||
|
///
|
||||||
|
/// Layout:
|
||||||
|
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
|
||||||
|
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
|
||||||
|
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
|
||||||
|
let admin_dir = data_dir.join("admin");
|
||||||
|
let users_1_dir = data_dir.join("users").join("1");
|
||||||
|
std::fs::create_dir_all(&admin_dir)?;
|
||||||
|
std::fs::create_dir_all(&users_1_dir)?;
|
||||||
|
|
||||||
|
let admin_files = [
|
||||||
|
"admin.db",
|
||||||
|
"admin.db-wal",
|
||||||
|
"admin.db-shm",
|
||||||
|
"system.db",
|
||||||
|
"system.db-wal",
|
||||||
|
"system.db-shm",
|
||||||
|
"users.db",
|
||||||
|
"users.db-wal",
|
||||||
|
"users.db-shm",
|
||||||
|
];
|
||||||
|
for f in admin_files {
|
||||||
|
let src = data_dir.join(f);
|
||||||
|
if src.exists() {
|
||||||
|
let dst = admin_dir.join(f);
|
||||||
|
if let Err(e) = std::fs::rename(&src, &dst) {
|
||||||
|
warn!(
|
||||||
|
file = f,
|
||||||
|
error = %e,
|
||||||
|
"failed to move restored admin file into admin/"
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_files = [
|
||||||
|
"content.db",
|
||||||
|
"content.db-wal",
|
||||||
|
"content.db-shm",
|
||||||
|
"analytics.db",
|
||||||
|
"analytics.db-wal",
|
||||||
|
"analytics.db-shm",
|
||||||
|
];
|
||||||
|
for f in content_files {
|
||||||
|
let src = data_dir.join(f);
|
||||||
|
if src.exists() {
|
||||||
|
let dst = users_1_dir.join(f);
|
||||||
|
if let Err(e) = std::fs::rename(&src, &dst) {
|
||||||
|
warn!(
|
||||||
|
file = f,
|
||||||
|
error = %e,
|
||||||
|
"failed to move restored content file into users/1/"
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths used when reopening connections after restore.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RestoredDbPaths {
|
||||||
|
pub admin: PathBuf,
|
||||||
|
pub system: PathBuf,
|
||||||
|
pub users: PathBuf,
|
||||||
|
pub content: PathBuf,
|
||||||
|
pub analytics: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestoredDbPaths {
|
||||||
|
pub fn from_data_dir(data_dir: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
admin: data_dir.join("admin/admin.db"),
|
||||||
|
system: data_dir.join("admin/system.db"),
|
||||||
|
users: data_dir.join("admin/users.db"),
|
||||||
|
content: data_dir.join("users/1/content.db"),
|
||||||
|
analytics: data_dir.join("users/1/analytics.db"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moves_flat_files_into_tenant_layout() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bzod_layout_{}", uuid::Uuid::new_v4()));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("admin.db"), b"a").unwrap();
|
||||||
|
fs::write(dir.join("system.db"), b"s").unwrap();
|
||||||
|
fs::write(dir.join("users.db"), b"u").unwrap();
|
||||||
|
fs::write(dir.join("content.db"), b"c").unwrap();
|
||||||
|
fs::write(dir.join("analytics.db"), b"an").unwrap();
|
||||||
|
|
||||||
|
normalize_restored_layout(&dir).unwrap();
|
||||||
|
|
||||||
|
assert!(dir.join("admin/admin.db").exists());
|
||||||
|
assert!(dir.join("admin/system.db").exists());
|
||||||
|
assert!(dir.join("admin/users.db").exists());
|
||||||
|
assert!(dir.join("users/1/content.db").exists());
|
||||||
|
assert!(dir.join("users/1/analytics.db").exists());
|
||||||
|
assert!(!dir.join("admin.db").exists());
|
||||||
|
assert!(!dir.join("content.db").exists());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//! Bulk URL creation business logic (transaction + slug reservation).
|
||||||
|
//!
|
||||||
|
//! Handlers own auth/HTTP; this module owns validation, reservation, and inserts.
|
||||||
|
|
||||||
|
use crate::auth::generate_token;
|
||||||
|
use crate::auth::password::hash_password;
|
||||||
|
use crate::models::Url;
|
||||||
|
use crate::utils::validation::validate_redirect_destination;
|
||||||
|
use rusqlite::{Connection, Transaction};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// One item in a bulk URL create request (mirrors the HTTP payload shape).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BulkUrlCreateItem {
|
||||||
|
pub destination: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub max_access_count: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BulkUrlError {
|
||||||
|
BadRequest(String),
|
||||||
|
Conflict(String),
|
||||||
|
Forbidden(String),
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BulkUrlError {
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::BadRequest(m) | Self::Conflict(m) | Self::Forbidden(m) | Self::Internal(m) => m,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
|
||||||
|
for slug in slugs {
|
||||||
|
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check that the tenant can accept `additional` new URLs.
|
||||||
|
pub fn ensure_url_quota(
|
||||||
|
users_db: &Mutex<Connection>,
|
||||||
|
user_id: i64,
|
||||||
|
additional: i64,
|
||||||
|
) -> Result<(), BulkUrlError> {
|
||||||
|
let users_conn = crate::utils::lock_db(users_db, "users_db")
|
||||||
|
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||||
|
match crate::db::users::get_user_quotas(&users_conn, user_id) {
|
||||||
|
Ok(Some(quotas)) => {
|
||||||
|
if quotas.current_urls + additional > quotas.max_urls {
|
||||||
|
Err(BulkUrlError::Forbidden("Quota limit exceeded".into()))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => Err(BulkUrlError::Forbidden("User quota not found".into())),
|
||||||
|
Err(e) => Err(BulkUrlError::Internal(format!("quota lookup failed: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create many URLs inside a single content transaction with global slug reservation.
|
||||||
|
pub fn create_urls_bulk(
|
||||||
|
content_db: &Mutex<Connection>,
|
||||||
|
system_db: &Mutex<Connection>,
|
||||||
|
users_db: &Mutex<Connection>,
|
||||||
|
owner_user_id: i64,
|
||||||
|
items: Vec<BulkUrlCreateItem>,
|
||||||
|
) -> Result<Vec<Url>, BulkUrlError> {
|
||||||
|
let mut conn = crate::utils::lock_db(content_db, "content_db")
|
||||||
|
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||||
|
let tx = conn.transaction().map_err(|e| {
|
||||||
|
BulkUrlError::Internal(format!("Failed to start database transaction: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut created_urls = Vec::new();
|
||||||
|
let mut reserved_slugs: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) {
|
||||||
|
Ok(url) => created_urls.push(url),
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.rollback();
|
||||||
|
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||||
|
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = tx.commit() {
|
||||||
|
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||||
|
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||||
|
}
|
||||||
|
return Err(BulkUrlError::Internal(format!(
|
||||||
|
"Failed to commit transaction: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate slugs
|
||||||
|
{
|
||||||
|
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||||
|
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||||
|
for url in &created_urls {
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||||
|
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment quota counters
|
||||||
|
{
|
||||||
|
let users_conn = crate::utils::lock_db(users_db, "users_db")
|
||||||
|
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||||
|
for _ in 0..created_urls.len() {
|
||||||
|
let _ = crate::db::users::increment_quota_counter(&users_conn, owner_user_id, "urls");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(created_urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_one_in_tx(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
system_db: &Mutex<Connection>,
|
||||||
|
owner_user_id: i64,
|
||||||
|
item: BulkUrlCreateItem,
|
||||||
|
reserved_slugs: &mut Vec<String>,
|
||||||
|
) -> Result<Url, BulkUrlError> {
|
||||||
|
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = generate_token(3);
|
||||||
|
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err(BulkUrlError::BadRequest(format!(
|
||||||
|
"Short code '{code}' must be 6 hex characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||||
|
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||||
|
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
|
||||||
|
&& !reserved_slugs.contains(&code);
|
||||||
|
if !available {
|
||||||
|
return Err(BulkUrlError::Conflict(format!(
|
||||||
|
"Short code '{code}' already exists"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
&code,
|
||||||
|
owner_user_id,
|
||||||
|
"url",
|
||||||
|
"",
|
||||||
|
"reserving",
|
||||||
|
) {
|
||||||
|
return Err(BulkUrlError::Internal(format!(
|
||||||
|
"Failed to reserve slug '{code}': {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
reserved_slugs.push(code.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash = if let Some(ref pwd) = item.password {
|
||||||
|
match hash_password(pwd) {
|
||||||
|
Ok(h) => Some(h),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(BulkUrlError::Internal(format!(
|
||||||
|
"Password hashing error: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if !validate_redirect_destination(&item.destination) {
|
||||||
|
return Err(BulkUrlError::BadRequest(format!(
|
||||||
|
"Invalid destination for item '{code}': must be a valid http(s) URL without control characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags = item.tags.unwrap_or_default();
|
||||||
|
crate::db::content::create_url_extended(
|
||||||
|
tx,
|
||||||
|
&code,
|
||||||
|
&item.destination,
|
||||||
|
item.title.as_deref(),
|
||||||
|
item.description.as_deref(),
|
||||||
|
&tags,
|
||||||
|
item.expires_at.as_deref(),
|
||||||
|
password_hash.as_deref(),
|
||||||
|
item.max_access_count,
|
||||||
|
)
|
||||||
|
.map_err(|e| BulkUrlError::Internal(format!("Database insert error: {e}")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//! Read-only audit of stored redirect destinations.
|
||||||
|
//!
|
||||||
|
//! Scans tenant content databases and classifies each `urls.destination` using
|
||||||
|
//! the same rules as write-path validation. Never rewrites or deletes data.
|
||||||
|
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::path::Path;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Summary counters for a destination audit run.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DestinationAuditReport {
|
||||||
|
pub scanned_users: usize,
|
||||||
|
pub total_urls: usize,
|
||||||
|
pub valid_http: usize,
|
||||||
|
pub valid_https: usize,
|
||||||
|
pub invalid: usize,
|
||||||
|
pub control_characters: usize,
|
||||||
|
pub unsupported_scheme: usize,
|
||||||
|
pub malformed: usize,
|
||||||
|
pub empty: usize,
|
||||||
|
pub too_long: usize,
|
||||||
|
pub non_ascii: usize,
|
||||||
|
/// Safe sample of invalid records: (owner_user_id, code, class_label).
|
||||||
|
/// Destination bodies are never included (may contain control chars / secrets).
|
||||||
|
pub invalid_samples: Vec<InvalidDestinationSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct InvalidDestinationSample {
|
||||||
|
pub owner_user_id: i64,
|
||||||
|
pub code: String,
|
||||||
|
pub url_id: String,
|
||||||
|
pub class: &'static str,
|
||||||
|
pub destination_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SAMPLES: usize = 50;
|
||||||
|
|
||||||
|
fn class_label(c: DestinationClass) -> &'static str {
|
||||||
|
match c {
|
||||||
|
DestinationClass::ValidHttp => "valid_http",
|
||||||
|
DestinationClass::ValidHttps => "valid_https",
|
||||||
|
DestinationClass::Empty => "empty",
|
||||||
|
DestinationClass::TooLong => "too_long",
|
||||||
|
DestinationClass::ControlCharacters => "control_characters",
|
||||||
|
DestinationClass::NonAscii => "non_ascii",
|
||||||
|
DestinationClass::UnsupportedScheme => "unsupported_scheme",
|
||||||
|
DestinationClass::Malformed => "malformed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a single destination and update report counters.
|
||||||
|
pub fn record_destination(
|
||||||
|
report: &mut DestinationAuditReport,
|
||||||
|
owner_user_id: i64,
|
||||||
|
code: &str,
|
||||||
|
url_id: &str,
|
||||||
|
destination: &str,
|
||||||
|
) {
|
||||||
|
report.total_urls += 1;
|
||||||
|
let class = classify_redirect_destination(destination);
|
||||||
|
match class {
|
||||||
|
DestinationClass::ValidHttp => report.valid_http += 1,
|
||||||
|
DestinationClass::ValidHttps => report.valid_https += 1,
|
||||||
|
DestinationClass::Empty => {
|
||||||
|
report.empty += 1;
|
||||||
|
report.invalid += 1;
|
||||||
|
}
|
||||||
|
DestinationClass::TooLong => {
|
||||||
|
report.too_long += 1;
|
||||||
|
report.invalid += 1;
|
||||||
|
}
|
||||||
|
DestinationClass::ControlCharacters => {
|
||||||
|
report.control_characters += 1;
|
||||||
|
report.invalid += 1;
|
||||||
|
}
|
||||||
|
DestinationClass::NonAscii => {
|
||||||
|
report.non_ascii += 1;
|
||||||
|
report.invalid += 1;
|
||||||
|
}
|
||||||
|
DestinationClass::UnsupportedScheme => {
|
||||||
|
report.unsupported_scheme += 1;
|
||||||
|
report.invalid += 1;
|
||||||
|
}
|
||||||
|
DestinationClass::Malformed => {
|
||||||
|
report.malformed += 1;
|
||||||
|
report.invalid += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES {
|
||||||
|
report.invalid_samples.push(InvalidDestinationSample {
|
||||||
|
owner_user_id,
|
||||||
|
code: code.to_string(),
|
||||||
|
url_id: url_id.to_string(),
|
||||||
|
class: class_label(class),
|
||||||
|
destination_len: destination.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan one content database connection for URL destinations.
|
||||||
|
pub fn audit_content_conn(
|
||||||
|
conn: &Connection,
|
||||||
|
owner_user_id: i64,
|
||||||
|
report: &mut DestinationAuditReport,
|
||||||
|
) -> rusqlite::Result<()> {
|
||||||
|
let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let (id, code, destination) = row?;
|
||||||
|
record_destination(report, owner_user_id, &code, &id, &destination);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> {
|
||||||
|
let path = data_dir
|
||||||
|
.join("users")
|
||||||
|
.join(user_id.to_string())
|
||||||
|
.join("content.db");
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(rusqlite::Error::InvalidPath(path));
|
||||||
|
}
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audit all tenant content databases found under the configured data directory.
|
||||||
|
///
|
||||||
|
/// Read-only: does not modify any records.
|
||||||
|
pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String> {
|
||||||
|
let mut report = DestinationAuditReport::default();
|
||||||
|
|
||||||
|
let user_ids: Vec<i64> = {
|
||||||
|
let users = db
|
||||||
|
.users
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("users_db lock poisoned: {}", e))?;
|
||||||
|
let mut stmt = users
|
||||||
|
.prepare("SELECT id FROM users;")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get(0))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for user_id in user_ids {
|
||||||
|
match open_user_content(&db.data_dir, user_id) {
|
||||||
|
Ok(conn) => {
|
||||||
|
report.scanned_users += 1;
|
||||||
|
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
|
||||||
|
error!(
|
||||||
|
owner_user_id = user_id,
|
||||||
|
error = %e,
|
||||||
|
"destination audit failed for user content.db"
|
||||||
|
);
|
||||||
|
return Err(format!("audit user {} content.db: {}", user_id, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::InvalidPath(_)) => {
|
||||||
|
// User has no content DB yet — skip.
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
owner_user_id = user_id,
|
||||||
|
error = %e,
|
||||||
|
"could not open user content.db for destination audit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
total_urls = report.total_urls,
|
||||||
|
valid = report.valid_http + report.valid_https,
|
||||||
|
invalid = report.invalid,
|
||||||
|
"destination audit complete"
|
||||||
|
);
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a human-readable report for CLI output.
|
||||||
|
pub fn format_report(report: &DestinationAuditReport) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str("BZOD Redirect Destination Audit (read-only)\n");
|
||||||
|
out.push_str("===========================================\n");
|
||||||
|
out.push_str(&format!("Users scanned: {}\n", report.scanned_users));
|
||||||
|
out.push_str(&format!("Total URLs: {}\n", report.total_urls));
|
||||||
|
out.push_str(&format!("Valid HTTP: {}\n", report.valid_http));
|
||||||
|
out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https));
|
||||||
|
out.push_str(&format!("Invalid (total): {}\n", report.invalid));
|
||||||
|
out.push_str(&format!(
|
||||||
|
" control characters: {}\n",
|
||||||
|
report.control_characters
|
||||||
|
));
|
||||||
|
out.push_str(&format!(
|
||||||
|
" unsupported scheme: {}\n",
|
||||||
|
report.unsupported_scheme
|
||||||
|
));
|
||||||
|
out.push_str(&format!(" malformed: {}\n", report.malformed));
|
||||||
|
out.push_str(&format!(" empty: {}\n", report.empty));
|
||||||
|
out.push_str(&format!(" too long: {}\n", report.too_long));
|
||||||
|
out.push_str(&format!(" non-ascii: {}\n", report.non_ascii));
|
||||||
|
|
||||||
|
if !report.invalid_samples.is_empty() {
|
||||||
|
out.push_str("\nInvalid samples (id/code only; destinations not printed):\n");
|
||||||
|
for s in &report.invalid_samples {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" user={} code={} id={} class={} dest_len={}\n",
|
||||||
|
s.owner_user_id, s.code, s.url_id, s.class, s.destination_len
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_control_character_destination() {
|
||||||
|
let mut report = DestinationAuditReport::default();
|
||||||
|
record_destination(
|
||||||
|
&mut report,
|
||||||
|
1,
|
||||||
|
"ab12cd",
|
||||||
|
"id-1",
|
||||||
|
"https://evil.example/\r\nX:1",
|
||||||
|
);
|
||||||
|
assert_eq!(report.total_urls, 1);
|
||||||
|
assert_eq!(report.invalid, 1);
|
||||||
|
assert_eq!(report.control_characters, 1);
|
||||||
|
assert_eq!(report.invalid_samples.len(), 1);
|
||||||
|
assert_eq!(report.invalid_samples[0].class, "control_characters");
|
||||||
|
// Ensure we never store the destination body in the sample.
|
||||||
|
assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_valid_https() {
|
||||||
|
let mut report = DestinationAuditReport::default();
|
||||||
|
record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok");
|
||||||
|
assert_eq!(report.valid_https, 1);
|
||||||
|
assert_eq!(report.invalid, 0);
|
||||||
|
assert!(report.invalid_samples.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod backup_layout;
|
||||||
pub mod bulk;
|
pub mod bulk;
|
||||||
|
pub mod bulk_urls;
|
||||||
|
pub mod destination_audit;
|
||||||
pub mod landing_pages;
|
pub mod landing_pages;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
pub mod registry_validator;
|
pub mod registry_validator;
|
||||||
pub mod shortener;
|
pub mod shortener;
|
||||||
|
pub mod slug_transfer;
|
||||||
|
pub mod urls;
|
||||||
@@ -10,13 +10,24 @@ pub fn create_url(
|
|||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
tags: &[String],
|
tags: &[String],
|
||||||
) -> Result<Url, AppError> {
|
) -> Result<Url, AppError> {
|
||||||
let conn = db.content.lock().unwrap();
|
if !crate::utils::validation::validate_redirect_destination(destination) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Destination must be a valid http(s) URL without control characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let conn = db
|
||||||
|
.content
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||||
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
|
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
|
||||||
Ok(url)
|
Ok(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
|
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
|
||||||
let conn = db.content.lock().unwrap();
|
let conn = db
|
||||||
|
.content
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||||
let url = crate::db::content::get_url_by_code(&conn, code)?;
|
let url = crate::db::content::get_url_by_code(&conn, code)?;
|
||||||
Ok(url)
|
Ok(url)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
//! Cross-tenant slug transfer business logic.
|
||||||
|
//!
|
||||||
|
//! Copies URL/page content between tenant content DBs, then updates global_slugs
|
||||||
|
//! ownership. Handlers own admin auth and HTTP mapping.
|
||||||
|
|
||||||
|
use crate::state::{AppState, UserDbs};
|
||||||
|
use crate::utils::lock_db;
|
||||||
|
use chrono::Utc;
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TransferError {
|
||||||
|
NotFound(&'static str),
|
||||||
|
BadRequest(String),
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransferError {
|
||||||
|
pub fn message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::NotFound(m) => (*m).to_string(),
|
||||||
|
Self::BadRequest(m) | Self::Internal(m) => m.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SlugTransferRequest {
|
||||||
|
pub slug: String,
|
||||||
|
pub new_owner_user_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SlugTransferResult {
|
||||||
|
pub old_owner_user_id: i64,
|
||||||
|
pub new_owner_user_id: i64,
|
||||||
|
pub target_type: String,
|
||||||
|
pub new_target_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up slug ownership in `global_slugs`.
|
||||||
|
pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> {
|
||||||
|
let system_conn = lock_db(&state.system_db, "system_db")
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
let mut stmt = system_conn
|
||||||
|
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;")
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
let row_opt = stmt
|
||||||
|
.query_row([slug], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
match row_opt {
|
||||||
|
Some(r) => Ok(r),
|
||||||
|
None => Err(TransferError::NotFound("Slug not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy content row between tenants and return the new target id.
|
||||||
|
fn copy_content(
|
||||||
|
state: &AppState,
|
||||||
|
old_dbs: &UserDbs,
|
||||||
|
new_dbs: &UserDbs,
|
||||||
|
slug: &str,
|
||||||
|
target_type: &str,
|
||||||
|
new_owner_user_id: i64,
|
||||||
|
) -> Result<String, TransferError> {
|
||||||
|
let old_conn = lock_db(&old_dbs.content, "old_content_db")
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
let new_conn = lock_db(&new_dbs.content, "new_content_db")
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if target_type == "url" {
|
||||||
|
let url = match crate::db::content::get_url_by_code(&old_conn, slug) {
|
||||||
|
Ok(Some(u)) => u,
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(TransferError::NotFound(
|
||||||
|
"Content not found in owner database",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => return Err(TransferError::Internal(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let new_users_conn = lock_db(&state.users_db, "users_db")
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
if let Ok(Some(quota)) =
|
||||||
|
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
|
||||||
|
{
|
||||||
|
if quota.current_urls >= quota.max_urls {
|
||||||
|
return Err(TransferError::BadRequest(
|
||||||
|
"New owner has exceeded URL quota limit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_url = crate::db::content::create_url_extended(
|
||||||
|
&new_conn,
|
||||||
|
&url.code,
|
||||||
|
&url.destination,
|
||||||
|
url.title.as_deref(),
|
||||||
|
url.description.as_deref(),
|
||||||
|
&url.tags,
|
||||||
|
url.expires_at.as_deref(),
|
||||||
|
url.password_hash.as_deref(),
|
||||||
|
url.max_access_count,
|
||||||
|
)
|
||||||
|
.map_err(|e| TransferError::Internal(format!("Failed to copy URL to new owner: {e}")))?;
|
||||||
|
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||||
|
Ok(new_url.id)
|
||||||
|
} else if target_type == "page" {
|
||||||
|
let page = match crate::db::content::get_landing_page_by_code(&old_conn, slug) {
|
||||||
|
Ok(Some(p)) => p,
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(TransferError::NotFound(
|
||||||
|
"Content not found in owner database",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => return Err(TransferError::Internal(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let new_users_conn = lock_db(&state.users_db, "users_db")
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
if let Ok(Some(quota)) =
|
||||||
|
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
|
||||||
|
{
|
||||||
|
if quota.current_landings >= quota.max_landings {
|
||||||
|
return Err(TransferError::BadRequest(
|
||||||
|
"New owner has exceeded landing page quota limit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_page = crate::db::content::create_landing_page(
|
||||||
|
&new_conn,
|
||||||
|
&page.code,
|
||||||
|
&page.slug,
|
||||||
|
&page.title,
|
||||||
|
&page.html_content,
|
||||||
|
&page.state,
|
||||||
|
)
|
||||||
|
.map_err(|e| TransferError::Internal(format!("Failed to copy Page to new owner: {e}")))?;
|
||||||
|
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||||
|
Ok(new_page.id)
|
||||||
|
} else {
|
||||||
|
Err(TransferError::NotFound(
|
||||||
|
"Content not found in owner database",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a full slug transfer (content + registry + quotas + history).
|
||||||
|
pub fn transfer_slug(
|
||||||
|
state: &AppState,
|
||||||
|
req: &SlugTransferRequest,
|
||||||
|
admin_username: &str,
|
||||||
|
) -> Result<SlugTransferResult, TransferError> {
|
||||||
|
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
|
||||||
|
|
||||||
|
if old_owner_user_id == req.new_owner_user_id {
|
||||||
|
return Err(TransferError::BadRequest(
|
||||||
|
"New owner must be different from the current owner".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_dbs = state
|
||||||
|
.get_user_dbs(old_owner_user_id)
|
||||||
|
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
|
||||||
|
let new_dbs = state
|
||||||
|
.get_user_dbs(req.new_owner_user_id)
|
||||||
|
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
|
||||||
|
|
||||||
|
let new_target_id = copy_content(
|
||||||
|
state,
|
||||||
|
&old_dbs,
|
||||||
|
&new_dbs,
|
||||||
|
&req.slug,
|
||||||
|
&target_type,
|
||||||
|
req.new_owner_user_id,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = lock_db(&state.system_db, "system_db")
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||||
|
rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug],
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||||
|
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||||
|
rusqlite::params![
|
||||||
|
req.slug,
|
||||||
|
old_owner_user_id,
|
||||||
|
req.new_owner_user_id,
|
||||||
|
now,
|
||||||
|
admin_username
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let users_conn = lock_db(&state.users_db, "users_db")
|
||||||
|
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||||
|
let field = if target_type == "url" {
|
||||||
|
"urls"
|
||||||
|
} else {
|
||||||
|
"landings"
|
||||||
|
};
|
||||||
|
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
|
||||||
|
let _ =
|
||||||
|
crate::db::users::increment_quota_counter(&users_conn, req.new_owner_user_id, field);
|
||||||
|
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
admin_username,
|
||||||
|
"SLUG_TRANSFER",
|
||||||
|
"slug",
|
||||||
|
&req.slug,
|
||||||
|
Some(&format!(
|
||||||
|
"From owner {} to owner {}",
|
||||||
|
old_owner_user_id, req.new_owner_user_id
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SlugTransferResult {
|
||||||
|
old_owner_user_id,
|
||||||
|
new_owner_user_id: req.new_owner_user_id,
|
||||||
|
target_type,
|
||||||
|
new_target_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! Shared URL write-path helpers used by admin UI, tenant UI, and REST API.
|
||||||
|
//!
|
||||||
|
//! Handlers remain responsible for auth/CSRF/quotas; this module owns pure
|
||||||
|
//! destination preparation that must stay consistent across entry points.
|
||||||
|
|
||||||
|
use crate::utils::validation::validate_redirect_destination;
|
||||||
|
|
||||||
|
/// Optional UTM parameters applied to a destination URL.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct UtmParams<'a> {
|
||||||
|
pub source: Option<&'a str>,
|
||||||
|
pub medium: Option<&'a str>,
|
||||||
|
pub campaign: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize and optionally append UTM parameters to a destination.
|
||||||
|
///
|
||||||
|
/// Returns `Err` when the base destination fails canonical validation.
|
||||||
|
/// UTM appending only runs when the base parses as a URL (same as prior handlers).
|
||||||
|
pub fn prepare_destination(raw: &str, utm: UtmParams<'_>) -> Result<String, &'static str> {
|
||||||
|
let mut dest = raw.trim().to_string();
|
||||||
|
if !validate_redirect_destination(&dest) {
|
||||||
|
return Err("Destination must be a valid http(s) URL without control characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
|
||||||
|
let mut has_utm = false;
|
||||||
|
{
|
||||||
|
let mut query = parsed.query_pairs_mut();
|
||||||
|
if let Some(src) = utm.source {
|
||||||
|
let src = src.trim();
|
||||||
|
if !src.is_empty() {
|
||||||
|
query.append_pair("utm_source", src);
|
||||||
|
has_utm = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(med) = utm.medium {
|
||||||
|
let med = med.trim();
|
||||||
|
if !med.is_empty() {
|
||||||
|
query.append_pair("utm_medium", med);
|
||||||
|
has_utm = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(camp) = utm.campaign {
|
||||||
|
let camp = camp.trim();
|
||||||
|
if !camp.is_empty() {
|
||||||
|
query.append_pair("utm_campaign", camp);
|
||||||
|
has_utm = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_utm {
|
||||||
|
dest = parsed.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse HTML datetime-local / partial RFC3339 expiry input into RFC3339 if present.
|
||||||
|
pub fn parse_expires_at_input(raw: &str) -> Option<String> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut rfc = trimmed.to_string();
|
||||||
|
if rfc.len() == 16 {
|
||||||
|
// HTML datetime-local → assume UTC seconds
|
||||||
|
rfc.push_str(":00Z");
|
||||||
|
}
|
||||||
|
Some(rfc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse optional max-access-count form field.
|
||||||
|
pub fn parse_max_access_count(raw: &str) -> Option<i64> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
trimmed.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_rejects_crlf() {
|
||||||
|
let err = prepare_destination("https://x/\r\nY:1", UtmParams::default()).unwrap_err();
|
||||||
|
assert!(err.contains("valid http"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prepare_appends_utm() {
|
||||||
|
let dest = prepare_destination(
|
||||||
|
"https://example.com/path",
|
||||||
|
UtmParams {
|
||||||
|
source: Some("newsletter"),
|
||||||
|
medium: Some("email"),
|
||||||
|
campaign: Some("spring"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(dest.contains("utm_source=newsletter"));
|
||||||
|
assert!(dest.contains("utm_medium=email"));
|
||||||
|
assert!(dest.contains("utm_campaign=spring"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_expires_datetime_local() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_expires_at_input("2030-01-01T12:00"),
|
||||||
|
Some("2030-01-01T12:00:00Z".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(parse_expires_at_input(" "), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-7
@@ -29,7 +29,7 @@ pub struct AppState {
|
|||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
|
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
|
||||||
let mut pool = self.user_dbs.lock().unwrap();
|
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||||
if let Some(dbs) = pool.get(&user_id) {
|
if let Some(dbs) = pool.get(&user_id) {
|
||||||
return Ok(dbs.clone());
|
return Ok(dbs.clone());
|
||||||
}
|
}
|
||||||
@@ -87,12 +87,12 @@ impl AppState {
|
|||||||
Ok(dbs)
|
Ok(dbs)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn db_compact(&self) -> Result<(), rusqlite::Error> {
|
pub fn db_compact(&self) -> Result<(), crate::error::AppError> {
|
||||||
self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
|
crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?;
|
||||||
self.content_db.lock().unwrap().execute("VACUUM;", [])?;
|
crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?;
|
||||||
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
|
crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?;
|
||||||
self.system_db.lock().unwrap().execute("VACUUM;", [])?;
|
crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?;
|
||||||
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
|
crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//! Poison-safe helpers for `std::sync::Mutex` around SQLite connections.
|
||||||
|
//!
|
||||||
|
//! Prefer these on request paths so a poisoned mutex returns a controlled error
|
||||||
|
//! instead of panicking the worker thread.
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
/// Acquire a database mutex, mapping poison to [`AppError::Internal`].
|
||||||
|
///
|
||||||
|
/// Logs the mutex name (not connection contents or secrets).
|
||||||
|
pub fn lock_db<'a, T>(
|
||||||
|
mutex: &'a Mutex<T>,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<MutexGuard<'a, T>, AppError> {
|
||||||
|
mutex.lock().map_err(|e| {
|
||||||
|
error!(mutex = name, error = %e, "database mutex poisoned");
|
||||||
|
AppError::Internal(format!("{name} mutex poisoned"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a database mutex, mapping poison to a plain error string.
|
||||||
|
///
|
||||||
|
/// Useful for handlers that return `(StatusCode, String)` rather than `AppError`.
|
||||||
|
pub fn lock_db_str<'a, T>(
|
||||||
|
mutex: &'a Mutex<T>,
|
||||||
|
name: &'static str,
|
||||||
|
) -> Result<MutexGuard<'a, T>, String> {
|
||||||
|
mutex.lock().map_err(|e| {
|
||||||
|
error!(mutex = name, error = %e, "database mutex poisoned");
|
||||||
|
format!("{name} mutex poisoned")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_db_succeeds_on_healthy_mutex() {
|
||||||
|
let m = Mutex::new(42);
|
||||||
|
let g = lock_db(&m, "test").unwrap();
|
||||||
|
assert_eq!(*g, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_db_maps_poison() {
|
||||||
|
let m = Mutex::new(1);
|
||||||
|
let _ = std::panic::catch_unwind(|| {
|
||||||
|
let _g = m.lock().unwrap();
|
||||||
|
panic!("poison");
|
||||||
|
});
|
||||||
|
let err = lock_db(&m, "poisoned_db").unwrap_err();
|
||||||
|
match err {
|
||||||
|
AppError::Internal(msg) => assert!(msg.contains("poisoned_db")),
|
||||||
|
other => panic!("unexpected {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod db_lock;
|
||||||
pub mod hashing;
|
pub mod hashing;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod random;
|
pub mod random;
|
||||||
@@ -5,8 +6,9 @@ pub mod system;
|
|||||||
pub mod time;
|
pub mod time;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|
||||||
|
pub use db_lock::{lock_db, lock_db_str};
|
||||||
pub use hashing::sha256_hash;
|
pub use hashing::sha256_hash;
|
||||||
pub use network::get_client_ip;
|
pub use network::{get_client_ip, resolve_cookie_secure};
|
||||||
pub use random::generate_token;
|
pub use random::generate_token;
|
||||||
pub use system::{get_db_file_info, get_memory_usage};
|
pub use system::{get_db_file_info, get_memory_usage};
|
||||||
pub use time::format_duration;
|
pub use time::format_duration;
|
||||||
@@ -22,3 +22,119 @@ pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<Socke
|
|||||||
}
|
}
|
||||||
"127.0.0.1".to_string()
|
"127.0.0.1".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to safely extract hostname from a Host header, handling IPv6 and ports.
|
||||||
|
pub(crate) fn extract_hostname(host_header: &str) -> &str {
|
||||||
|
if host_header.starts_with('[') {
|
||||||
|
if let Some(end_idx) = host_header.find(']') {
|
||||||
|
return &host_header[1..end_idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host_header.split(':').next().unwrap_or(host_header)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines whether to set the `Secure` flag on a cookie based on deployment context.
|
||||||
|
///
|
||||||
|
/// Policy:
|
||||||
|
/// 1. If X-Forwarded-Proto is explicitly "https", always enforce Secure=true.
|
||||||
|
/// (We assume X-Forwarded-Proto is from a trusted proxy. Forged "https" only makes
|
||||||
|
/// the cookie safer. We never weaken based on X-Forwarded-Proto=http).
|
||||||
|
/// 2. If the exact Host is a local loopback (localhost, 127.0.0.1, ::1) and we are not
|
||||||
|
/// explicitly proxied via HTTPS, disable Secure. This prevents browsers from dropping
|
||||||
|
/// the cookie during local development over cleartext HTTP.
|
||||||
|
/// 3. Otherwise, fall back to the global `cookie_secure` config (which defaults to true
|
||||||
|
/// to keep production secure-by-default even if the proxy strips X-Forwarded-Proto).
|
||||||
|
pub fn resolve_cookie_secure(config_secure: bool, headers: &HeaderMap) -> bool {
|
||||||
|
// 1. Explicit HTTPS via reverse proxy
|
||||||
|
if let Some(proto) = headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
{
|
||||||
|
if proto.eq_ignore_ascii_case("https") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Exact loopback development (prevent cookie drop)
|
||||||
|
if let Some(host_hdr) = headers.get("host").and_then(|h| h.to_str().ok()) {
|
||||||
|
let hostname = extract_hostname(host_hdr);
|
||||||
|
if matches!(hostname, "localhost" | "127.0.0.1" | "::1") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Global config (normally true)
|
||||||
|
config_secure
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_hostname() {
|
||||||
|
assert_eq!(extract_hostname("localhost"), "localhost");
|
||||||
|
assert_eq!(extract_hostname("localhost:8080"), "localhost");
|
||||||
|
assert_eq!(extract_hostname("127.0.0.1"), "127.0.0.1");
|
||||||
|
assert_eq!(extract_hostname("127.0.0.1:8080"), "127.0.0.1");
|
||||||
|
assert_eq!(extract_hostname("[::1]"), "::1");
|
||||||
|
assert_eq!(extract_hostname("[::1]:8080"), "::1");
|
||||||
|
assert_eq!(extract_hostname("example.com"), "example.com");
|
||||||
|
assert_eq!(extract_hostname("example.com:443"), "example.com");
|
||||||
|
assert_eq!(
|
||||||
|
extract_hostname("localhost.example.com"),
|
||||||
|
"localhost.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_cookie_secure() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
|
||||||
|
// No headers, global config is true -> Secure=true
|
||||||
|
assert!(resolve_cookie_secure(true, &headers));
|
||||||
|
// No headers, global config is false -> Secure=false
|
||||||
|
assert!(!resolve_cookie_secure(false, &headers));
|
||||||
|
|
||||||
|
// Exact loopback matches -> Secure=false
|
||||||
|
let loopback_hosts = [
|
||||||
|
"localhost",
|
||||||
|
"localhost:8080",
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.0.0.1:8080",
|
||||||
|
"[::1]",
|
||||||
|
"[::1]:8080",
|
||||||
|
];
|
||||||
|
for host in loopback_hosts {
|
||||||
|
headers.insert("host", HeaderValue::from_static(host));
|
||||||
|
assert!(
|
||||||
|
!resolve_cookie_secure(true, &headers),
|
||||||
|
"Failed for host: {}",
|
||||||
|
host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-loopback localhost subdomains -> Secure=true (relying on config)
|
||||||
|
let public_hosts = ["localhost.example.com", "127.0.0.2", "example.com"];
|
||||||
|
for host in public_hosts {
|
||||||
|
headers.insert("host", HeaderValue::from_static(host));
|
||||||
|
assert!(
|
||||||
|
resolve_cookie_secure(true, &headers),
|
||||||
|
"Failed for host: {}",
|
||||||
|
host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Forwarded-Proto = https overrides loopback
|
||||||
|
headers.insert("host", HeaderValue::from_static("localhost"));
|
||||||
|
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
|
||||||
|
assert!(resolve_cookie_secure(true, &headers));
|
||||||
|
assert!(resolve_cookie_secure(false, &headers)); // Overrides false config too
|
||||||
|
|
||||||
|
// X-Forwarded-Proto = http DOES NOT override global config
|
||||||
|
headers.insert("host", HeaderValue::from_static("example.com"));
|
||||||
|
headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
|
||||||
|
assert!(resolve_cookie_secure(true, &headers)); // Still true because of config
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,144 @@ pub fn validate_redirect_code(code: &str) -> bool {
|
|||||||
pub fn validate_page_code(code: &str) -> bool {
|
pub fn validate_page_code(code: &str) -> bool {
|
||||||
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Classification of a stored or proposed redirect destination.
|
||||||
|
///
|
||||||
|
/// Used by write-path validation and read-only legacy data audits. Order of checks
|
||||||
|
/// matches `validate_redirect_destination` so both share the same rules.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DestinationClass {
|
||||||
|
ValidHttp,
|
||||||
|
ValidHttps,
|
||||||
|
Empty,
|
||||||
|
TooLong,
|
||||||
|
ControlCharacters,
|
||||||
|
NonAscii,
|
||||||
|
UnsupportedScheme,
|
||||||
|
Malformed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DestinationClass {
|
||||||
|
pub fn is_valid(self) -> bool {
|
||||||
|
matches!(self, Self::ValidHttp | Self::ValidHttps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheme_prefix(dest: &str) -> Option<&str> {
|
||||||
|
let end = dest.find(':')?;
|
||||||
|
let scheme = &dest[..end];
|
||||||
|
if scheme.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if scheme
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
|
||||||
|
{
|
||||||
|
Some(scheme)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a destination using the same rules as write-path validation.
|
||||||
|
pub fn classify_redirect_destination(destination: &str) -> DestinationClass {
|
||||||
|
let dest = destination.trim();
|
||||||
|
if dest.is_empty() {
|
||||||
|
return DestinationClass::Empty;
|
||||||
|
}
|
||||||
|
if dest.len() > 2048 {
|
||||||
|
return DestinationClass::TooLong;
|
||||||
|
}
|
||||||
|
// HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls.
|
||||||
|
if dest.bytes().any(|b| b < 0x20 || b == 0x7f) {
|
||||||
|
return DestinationClass::ControlCharacters;
|
||||||
|
}
|
||||||
|
// HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII.
|
||||||
|
if !dest.is_ascii() {
|
||||||
|
return DestinationClass::NonAscii;
|
||||||
|
}
|
||||||
|
// Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:).
|
||||||
|
if let Some(scheme) = scheme_prefix(dest) {
|
||||||
|
let scheme_l = scheme.to_ascii_lowercase();
|
||||||
|
if scheme_l != "http" && scheme_l != "https" {
|
||||||
|
return DestinationClass::UnsupportedScheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match reqwest::Url::parse(dest) {
|
||||||
|
Ok(url) => {
|
||||||
|
if url.host_str().is_none() {
|
||||||
|
return DestinationClass::Malformed;
|
||||||
|
}
|
||||||
|
match url.scheme() {
|
||||||
|
"http" => DestinationClass::ValidHttp,
|
||||||
|
"https" => DestinationClass::ValidHttps,
|
||||||
|
_ => DestinationClass::UnsupportedScheme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => DestinationClass::Malformed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `destination` is safe to store and emit as an HTTP Location value.
|
||||||
|
///
|
||||||
|
/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values,
|
||||||
|
/// and non-http(s) schemes. The redirect handler remains defensive even if invalid
|
||||||
|
/// values already exist in older data.
|
||||||
|
pub fn validate_redirect_destination(destination: &str) -> bool {
|
||||||
|
classify_redirect_destination(destination).is_valid()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_crlf_destination() {
|
||||||
|
assert!(!validate_redirect_destination(
|
||||||
|
"https://example.com/\r\nX-Injected: 1"
|
||||||
|
));
|
||||||
|
assert!(!validate_redirect_destination(
|
||||||
|
"https://example.com/\nX-Injected: 1"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_http_schemes() {
|
||||||
|
assert!(!validate_redirect_destination("javascript:alert(1)"));
|
||||||
|
assert!(!validate_redirect_destination("data:text/html,hi"));
|
||||||
|
assert!(!validate_redirect_destination("/relative/path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_normal_https() {
|
||||||
|
assert!(validate_redirect_destination(
|
||||||
|
"https://example.com/path?q=1#frag"
|
||||||
|
));
|
||||||
|
assert!(validate_redirect_destination("http://localhost:8080/x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_destination_categories() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("https://ok.example/"),
|
||||||
|
DestinationClass::ValidHttps
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("http://ok.example/"),
|
||||||
|
DestinationClass::ValidHttp
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("javascript:alert(1)"),
|
||||||
|
DestinationClass::UnsupportedScheme
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("https://x/\r\nX:1"),
|
||||||
|
DestinationClass::ControlCharacters
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("not a url"),
|
||||||
|
DestinationClass::Malformed
|
||||||
|
);
|
||||||
|
assert_eq!(classify_redirect_destination(""), DestinationClass::Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
-7065
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,236 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateApiKeyForm {
|
||||||
|
pub key_name: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/settings/api-keys/create
|
||||||
|
pub async fn create_api_key_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Form(form): Form<CreateApiKeyForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
let key_secret = format!("bzo_{}", generate_token(16));
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(key_secret.as_bytes());
|
||||||
|
let hashed_key = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
|
let conn = state.admin_db.lock().unwrap();
|
||||||
|
match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) {
|
||||||
|
Ok(api_key) => {
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"API_KEY_CREATED",
|
||||||
|
Some("api_key"),
|
||||||
|
Some(&api_key.id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}",
|
||||||
|
key_secret
|
||||||
|
)).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/settings/api-keys/revoke/:id
|
||||||
|
pub async fn revoke_api_key_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &csrf_token) {
|
||||||
|
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
|
||||||
|
let conn = state.admin_db.lock().unwrap();
|
||||||
|
match delete_api_key(&conn, &id) {
|
||||||
|
Ok(_) => {
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"API_KEY_REVOKED",
|
||||||
|
Some("api_key"),
|
||||||
|
Some(&id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/settings?success=API Token revoked").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!(
|
||||||
|
"/admin/settings?error=Failed to revoke key: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api-tokens
|
||||||
|
pub async fn api_tokens_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tokens = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([user.id], |row| {
|
||||||
|
Ok(crate::models::UserApiToken {
|
||||||
|
id: row.get(0)?,
|
||||||
|
user_id: row.get(1)?,
|
||||||
|
token_hash: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::ApiTokensTemplate {
|
||||||
|
admin_username: user.username.clone(),
|
||||||
|
username: user.username,
|
||||||
|
tokens,
|
||||||
|
new_token: params.get("new_token").cloned(),
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api-tokens/create
|
||||||
|
pub async fn api_tokens_create_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
use sha2::Digest;
|
||||||
|
let raw_token = format!("key_{}", generate_token(32));
|
||||||
|
let mut hasher = sha2::Sha256::new();
|
||||||
|
hasher.update(raw_token.as_bytes());
|
||||||
|
let hashed_token = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
|
||||||
|
rusqlite::params![user.id, hashed_token, now],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn_sys = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&conn_sys,
|
||||||
|
&user.username,
|
||||||
|
"API_TOKEN_CREATED",
|
||||||
|
"api_token",
|
||||||
|
"new",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/api-tokens?new_token={}&success=Token generated successfully",
|
||||||
|
raw_token
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api-tokens/revoke/:id
|
||||||
|
pub async fn api_tokens_revoke_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(token_id): Path<i64>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
|
||||||
|
[token_id, user.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn_sys = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&conn_sys,
|
||||||
|
&user.username,
|
||||||
|
"API_TOKEN_REVOKED",
|
||||||
|
"api_token",
|
||||||
|
&token_id.to_string(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to("/api-tokens?success=API token revoked").into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// GET /admin/audit
|
||||||
|
pub async fn audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
let (user, _) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let logs = {
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None)
|
||||||
|
.unwrap_or_default();
|
||||||
|
events
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let (ip, ua) = if let Some(ref m) = e.metadata {
|
||||||
|
if m.starts_with("IP: ") {
|
||||||
|
let parts: Vec<&str> = m.split(", UA: ").collect();
|
||||||
|
let ip = parts[0]
|
||||||
|
.trim_start_matches("IP: ")
|
||||||
|
.trim_matches('"')
|
||||||
|
.trim_matches('\'')
|
||||||
|
.replace("Some(", "")
|
||||||
|
.replace(")", "");
|
||||||
|
let ua = if parts.len() > 1 {
|
||||||
|
parts[1]
|
||||||
|
.trim_matches('"')
|
||||||
|
.trim_matches('\'')
|
||||||
|
.replace("Some(", "")
|
||||||
|
.replace(")", "")
|
||||||
|
} else {
|
||||||
|
"Unknown".to_string()
|
||||||
|
};
|
||||||
|
(Some(ip), Some(ua))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::models::AuditLog {
|
||||||
|
id: e.id,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
username: e.actor,
|
||||||
|
action: e.action,
|
||||||
|
object_type: Some(e.object_type),
|
||||||
|
object_id: Some(e.object_id),
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: ua,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let template = crate::templates::AuditTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
logs,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /user/audit
|
||||||
|
pub async fn user_audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
let (user, _) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let logs = {
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let events =
|
||||||
|
crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None)
|
||||||
|
.unwrap_or_default();
|
||||||
|
events
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let (ip, ua) = if let Some(ref m) = e.metadata {
|
||||||
|
if m.starts_with("IP: ") {
|
||||||
|
let parts: Vec<&str> = m.split(", UA: ").collect();
|
||||||
|
let ip = parts[0]
|
||||||
|
.trim_start_matches("IP: ")
|
||||||
|
.trim_matches('"')
|
||||||
|
.trim_matches('\'')
|
||||||
|
.replace("Some(", "")
|
||||||
|
.replace(")", "");
|
||||||
|
let ua = if parts.len() > 1 {
|
||||||
|
parts[1]
|
||||||
|
.trim_matches('"')
|
||||||
|
.trim_matches('\'')
|
||||||
|
.replace("Some(", "")
|
||||||
|
.replace(")", "")
|
||||||
|
} else {
|
||||||
|
"Unknown".to_string()
|
||||||
|
};
|
||||||
|
(Some(ip), Some(ua))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::models::AuditLog {
|
||||||
|
id: e.id,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
username: e.actor,
|
||||||
|
action: e.action,
|
||||||
|
object_type: Some(e.object_type),
|
||||||
|
object_id: Some(e.object_id),
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: ua,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let template = crate::templates::UserAuditTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
logs,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// GET /admin
|
||||||
|
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
match require_auth(&state, &jar).await {
|
||||||
|
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
|
||||||
|
Err(redir) => redir.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/login
|
||||||
|
pub async fn login_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let error = params.get("error").cloned();
|
||||||
|
let csrf_token = generate_token(16);
|
||||||
|
|
||||||
|
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||||
|
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
|
||||||
|
.path("/admin/login")
|
||||||
|
.secure(secure_flag)
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||||
|
.max_age(time::Duration::minutes(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let new_jar = jar.add(cookie);
|
||||||
|
let template = crate::templates::LoginTemplate {
|
||||||
|
error,
|
||||||
|
csrf_token,
|
||||||
|
action: "/admin/login".to_string(),
|
||||||
|
title: "Admin Login".to_string(),
|
||||||
|
subtitle: "Administrative Access".to_string(),
|
||||||
|
button_text: "Sign In".to_string(),
|
||||||
|
};
|
||||||
|
(new_jar, template).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginForm {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bootstrap is allowed only when the system has no real admin yet.
|
||||||
|
pub(crate) fn is_bootstrap_allowed(
|
||||||
|
user_count: i64,
|
||||||
|
admin_count: i64,
|
||||||
|
active_session_count: i64,
|
||||||
|
) -> bool {
|
||||||
|
user_count <= 1 && admin_count == 0 && active_session_count == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_login_bootstrap_state(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
) -> Result<(i64, i64, i64), rusqlite::Error> {
|
||||||
|
let u_count: i64 = conn.query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0))?;
|
||||||
|
let a_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let s_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;",
|
||||||
|
[now],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
Ok((u_count, a_count, s_count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an existing admin tenant user may log into the admin UI.
|
||||||
|
///
|
||||||
|
/// Does not log the password. Rejection reasons are structured for observability.
|
||||||
|
pub(crate) fn verify_admin_credentials(
|
||||||
|
user: &crate::models::TenantUser,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
if user.status != "active" {
|
||||||
|
return Err("account_disabled");
|
||||||
|
}
|
||||||
|
if user.account_type != "admin" {
|
||||||
|
return Err("insufficient_privileges");
|
||||||
|
}
|
||||||
|
if !verify_password(password, &user.password_hash) {
|
||||||
|
return Err("invalid_credentials");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_tenant_user_by_username(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||||
|
FROM users WHERE username = ?1;",
|
||||||
|
[username],
|
||||||
|
|row| {
|
||||||
|
Ok(crate::models::TenantUser {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
password_hash: row.get(2)?,
|
||||||
|
status: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
last_login: row.get(5)?,
|
||||||
|
account_type: row.get(6)?,
|
||||||
|
organization_id: row.get(7)?,
|
||||||
|
metadata: row.get(8)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
|
||||||
|
User {
|
||||||
|
id: u.id.to_string(),
|
||||||
|
username: u.username,
|
||||||
|
password_hash: u.password_hash,
|
||||||
|
created_at: u.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audit_meta(ip: &str, headers: &HeaderMap) -> String {
|
||||||
|
format!(
|
||||||
|
"IP: {:?}, UA: {:?}",
|
||||||
|
ip,
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/login
|
||||||
|
pub async fn login_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Form(form): Form<LoginForm>,
|
||||||
|
) -> Response {
|
||||||
|
let temp_csrf = jar
|
||||||
|
.get("bzod_temp_csrf")
|
||||||
|
.map(|c| c.value().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
|
||||||
|
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
|
||||||
|
let bootstrap_allowed = {
|
||||||
|
let conn = match state.users_db.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match count_login_bootstrap_state(&conn) {
|
||||||
|
Ok((u, a, s)) => is_bootstrap_allowed(u, a, s),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "login bootstrap state query failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_opt = if bootstrap_allowed
|
||||||
|
&& form.username == state.config.admin_username
|
||||||
|
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
|
||||||
|
{
|
||||||
|
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
|
||||||
|
let hash = match hash_password(&form.password) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/admin/login?error=Internal hashing error").into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let conn = match state.users_db.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match crate::db::users::create_admin_user(&conn, &form.username, &hash) {
|
||||||
|
Ok(u) => {
|
||||||
|
if let Err(e) = state.db.init_user_databases(u.id) {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to init user databases during admin bootstrap: {:?}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(system_conn) = state.system_db.lock() {
|
||||||
|
let metadata = audit_meta(&ip, &headers);
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&u.username,
|
||||||
|
"BOOTSTRAP_USER_PROVISIONED",
|
||||||
|
"user",
|
||||||
|
&u.id.to_string(),
|
||||||
|
Some(&metadata),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(User {
|
||||||
|
id: u.id.to_string(),
|
||||||
|
username: u.username,
|
||||||
|
password_hash: u.password_hash,
|
||||||
|
created_at: u.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to create admin user during bootstrap: {:?}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let conn = match state.users_db.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match load_tenant_user_by_username(&conn, &form.username) {
|
||||||
|
Ok(Some(u)) => match verify_admin_credentials(&u, &form.password) {
|
||||||
|
Ok(()) => Some(tenant_user_to_admin_user(u)),
|
||||||
|
Err(reason) => {
|
||||||
|
tracing::warn!(username = form.username, reason, "login rejected");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::warn!(
|
||||||
|
username = form.username,
|
||||||
|
reason = "user_not_found",
|
||||||
|
"login rejected"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "login user lookup failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match user_opt {
|
||||||
|
Some(user) => {
|
||||||
|
let session_token = generate_token(32);
|
||||||
|
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = match state.users_db.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let user_id_i64 = user.id.parse::<i64>().unwrap_or(0);
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
if let Err(e) = conn.execute(
|
||||||
|
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||||
|
rusqlite::params![session_token, user_id_i64, expires, now],
|
||||||
|
) {
|
||||||
|
tracing::error!(error = %e, "failed to insert admin session");
|
||||||
|
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(system_conn) = state.system_db.lock() {
|
||||||
|
let metadata = audit_meta(&ip, &headers);
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"USER_LOGIN",
|
||||||
|
"session",
|
||||||
|
&session_token,
|
||||||
|
Some(&metadata),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let secure_flag =
|
||||||
|
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||||
|
let cookie = Cookie::build(("bzod_session", session_token))
|
||||||
|
.path("/")
|
||||||
|
.secure(secure_flag)
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||||
|
.max_age(time::Duration::days(30))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||||
|
.path("/admin/login")
|
||||||
|
.max_age(time::Duration::ZERO)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut response_jar = jar.clone();
|
||||||
|
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||||
|
|
||||||
|
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if let Ok(system_conn) = state.system_db.lock() {
|
||||||
|
let metadata = audit_meta(&ip, &headers);
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
"anonymous",
|
||||||
|
"LOGIN_FAILED",
|
||||||
|
"login",
|
||||||
|
"",
|
||||||
|
Some(&metadata),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Redirect::to("/admin/login?error=Invalid username or password").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /logout
|
||||||
|
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
let cookie = Cookie::build("bzod_user_session")
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::ZERO)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut response_jar = jar.clone();
|
||||||
|
response_jar = response_jar.add(cookie);
|
||||||
|
|
||||||
|
(response_jar, Redirect::to("/login")).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /login
|
||||||
|
pub async fn public_login_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let error = params.get("error").cloned();
|
||||||
|
let csrf_token = generate_token(16);
|
||||||
|
|
||||||
|
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||||
|
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
|
||||||
|
.path("/login")
|
||||||
|
.secure(secure_flag)
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||||
|
.max_age(time::Duration::minutes(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let new_jar = jar.add(cookie);
|
||||||
|
let template = crate::templates::LoginTemplate {
|
||||||
|
error,
|
||||||
|
csrf_token,
|
||||||
|
action: "/login".to_string(),
|
||||||
|
title: "User Login".to_string(),
|
||||||
|
subtitle: "Standard account access".to_string(),
|
||||||
|
button_text: "Sign In".to_string(),
|
||||||
|
};
|
||||||
|
(new_jar, template).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /login
|
||||||
|
pub async fn public_login_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Form(form): Form<LoginForm>,
|
||||||
|
) -> Response {
|
||||||
|
let temp_csrf = jar
|
||||||
|
.get("bzod_temp_csrf")
|
||||||
|
.map(|c| c.value().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
|
||||||
|
return Redirect::to("/login?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
|
||||||
|
let user_opt: Option<crate::models::TenantUser> = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row(
|
||||||
|
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||||
|
FROM users WHERE username = ?1;",
|
||||||
|
[&form.username],
|
||||||
|
|row| {
|
||||||
|
Ok(crate::models::TenantUser {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
password_hash: row.get(2)?,
|
||||||
|
status: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
last_login: row.get(5)?,
|
||||||
|
account_type: row.get(6)?,
|
||||||
|
organization_id: row.get(7)?,
|
||||||
|
metadata: row.get(8)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
).optional();
|
||||||
|
|
||||||
|
match user_res {
|
||||||
|
Ok(Some(u)) => {
|
||||||
|
if u.status != "active" {
|
||||||
|
None
|
||||||
|
} else if verify_password(&form.password, &u.password_hash) {
|
||||||
|
Some(u)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match user_opt {
|
||||||
|
Some(user) => {
|
||||||
|
let session_token = generate_token(32);
|
||||||
|
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let _ =
|
||||||
|
crate::db::users::create_user_session(&conn, &session_token, user.id, &expires);
|
||||||
|
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let metadata = format!(
|
||||||
|
"IP: {:?}, UA: {:?}",
|
||||||
|
ip,
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||||
|
);
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"USER_LOGIN",
|
||||||
|
"session",
|
||||||
|
&session_token,
|
||||||
|
Some(&metadata),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let secure_flag =
|
||||||
|
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||||
|
let cookie = Cookie::build(("bzod_user_session", session_token))
|
||||||
|
.path("/")
|
||||||
|
.secure(secure_flag)
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||||
|
.max_age(time::Duration::days(30))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||||
|
.path("/login")
|
||||||
|
.max_age(time::Duration::ZERO)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut response_jar = jar.clone();
|
||||||
|
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||||
|
|
||||||
|
(response_jar, Redirect::to("/user/dashboard")).into_response()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let metadata = format!(
|
||||||
|
"IP: {:?}, UA: {:?}",
|
||||||
|
ip,
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||||
|
);
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
"anonymous",
|
||||||
|
"LOGIN_FAILED",
|
||||||
|
"login",
|
||||||
|
"",
|
||||||
|
Some(&metadata),
|
||||||
|
);
|
||||||
|
Redirect::to("/login?error=Invalid username or password").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/logout
|
||||||
|
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookie = Cookie::build("bzod_session")
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::ZERO)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut response_jar = jar.clone();
|
||||||
|
response_jar = response_jar.add(cookie);
|
||||||
|
|
||||||
|
(response_jar, Redirect::to("/admin/login")).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod login_helpers_tests {
|
||||||
|
use super::is_bootstrap_allowed;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_only_when_no_admin() {
|
||||||
|
assert!(is_bootstrap_allowed(0, 0, 0));
|
||||||
|
assert!(is_bootstrap_allowed(1, 0, 0));
|
||||||
|
assert!(!is_bootstrap_allowed(2, 0, 0));
|
||||||
|
assert!(!is_bootstrap_allowed(1, 1, 0));
|
||||||
|
assert!(!is_bootstrap_allowed(1, 0, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct BackupFileRow {
|
||||||
|
pub filename: String,
|
||||||
|
pub size_str: String,
|
||||||
|
pub created_str: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct BackupHistoryRow {
|
||||||
|
pub id: String,
|
||||||
|
pub backup_path: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/backups
|
||||||
|
pub async fn backups_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut files = vec![];
|
||||||
|
if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) {
|
||||||
|
for entry in dir_entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() {
|
||||||
|
if let Some(filename) = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
{
|
||||||
|
if filename.ends_with(".tar.gz") {
|
||||||
|
let meta = entry.metadata().unwrap();
|
||||||
|
let size_str = format_size(meta.len());
|
||||||
|
let created_str = meta
|
||||||
|
.created()
|
||||||
|
.ok()
|
||||||
|
.map(|c| {
|
||||||
|
let datetime: chrono::DateTime<chrono::Utc> = c.into();
|
||||||
|
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
files.push(BackupFileRow {
|
||||||
|
filename,
|
||||||
|
size_str,
|
||||||
|
created_str,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||||
|
|
||||||
|
let history = {
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(BackupHistoryRow {
|
||||||
|
id: row.get(0)?,
|
||||||
|
backup_path: row.get(1)?,
|
||||||
|
status: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
size_bytes: row.get(4)?,
|
||||||
|
error_message: row.get(5)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::BackupsTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
files,
|
||||||
|
history,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/backups/create
|
||||||
|
pub async fn backups_create_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (_user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::jobs::backup::perform_backup(&state.db, &state.config).await {
|
||||||
|
Ok(path) => {
|
||||||
|
let filename = std::path::Path::new(&path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("backup.tar.gz");
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/admin/backups?success=Backup created successfully: {}",
|
||||||
|
filename
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!(
|
||||||
|
"/admin/backups?error=Failed to generate backup: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/backups/download/:filename
|
||||||
|
pub async fn backups_download_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
if require_auth(&state, &jar).await.is_err() {
|
||||||
|
return StatusCode::UNAUTHORIZED.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_path = state.config.backup_dir.join(&filename);
|
||||||
|
if !backup_path.exists() {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::read(&backup_path) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let body = axum::body::Body::from(bytes);
|
||||||
|
Response::builder()
|
||||||
|
.header("content-type", "application/octet-stream")
|
||||||
|
.header(
|
||||||
|
"content-disposition",
|
||||||
|
format!("attachment; filename=\"{}\"", filename),
|
||||||
|
)
|
||||||
|
.body(body)
|
||||||
|
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/backups/delete/:filename
|
||||||
|
pub async fn backups_delete_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
|
return Redirect::to("/admin/backups?error=Invalid filename").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_path = state.config.backup_dir.join(&filename);
|
||||||
|
if !backup_path.exists() {
|
||||||
|
return Redirect::to("/admin/backups?error=Backup file not found").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::remove_file(&backup_path) {
|
||||||
|
Ok(_) => {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"BACKUP_DELETE",
|
||||||
|
"backup",
|
||||||
|
&filename,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/backups?success=Backup archive deleted").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!(
|
||||||
|
"/admin/backups?error=Failed to delete backup file: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/backups/restore
|
||||||
|
pub async fn backups_restore_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
mut multipart: axum::extract::Multipart,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut backup_bytes = vec![];
|
||||||
|
let mut confirm_text = String::new();
|
||||||
|
let mut csrf_token = String::new();
|
||||||
|
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
|
if name == "backup_file" {
|
||||||
|
if let Ok(bytes) = field.bytes().await {
|
||||||
|
backup_bytes = bytes.to_vec();
|
||||||
|
}
|
||||||
|
} else if name == "confirm_text" {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
confirm_text = text.trim().to_string();
|
||||||
|
}
|
||||||
|
} else if name == "csrf_token" {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
csrf_token = text.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &csrf_token) {
|
||||||
|
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirm_text != "RESTORE" {
|
||||||
|
return Redirect::to(
|
||||||
|
"/admin/backups?error=Confirmation text mismatch. Please type RESTORE.",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if backup_bytes.is_empty() {
|
||||||
|
return Redirect::to("/admin/backups?error=Backup file is empty or missing.")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz");
|
||||||
|
if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) {
|
||||||
|
return Redirect::to(&format!(
|
||||||
|
"/admin/backups?error=Failed to save uploaded file: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::cli::restore::run(
|
||||||
|
temp_file_path.to_string_lossy().to_string(),
|
||||||
|
None,
|
||||||
|
state.config.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
let _ = std::fs::remove_file(&temp_file_path);
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = conn.execute("DELETE FROM sessions;", []);
|
||||||
|
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"RESTORE_EXECUTION",
|
||||||
|
"backup",
|
||||||
|
"upload",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/login?success=Restore successful. Please log in again.")
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(&temp_file_path);
|
||||||
|
Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// GET /user/dashboard
|
||||||
|
pub async fn user_dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_dbs = match state.get_user_dbs(user.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (total_urls, active_links, dead_links) = {
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_pages = {
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
get_landing_page_count(&conn).unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_clicks = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
get_total_clicks(&conn).unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let clicks_data = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
get_clicks_trend(&conn, "url", "all", 30)
|
||||||
|
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut trend_map = std::collections::BTreeMap::new();
|
||||||
|
for i in (0..30).rev() {
|
||||||
|
let date_str = (Utc::now() - chrono::Duration::days(i))
|
||||||
|
.format("%Y-%m-%d")
|
||||||
|
.to_string();
|
||||||
|
trend_map.insert(date_str, 0i64);
|
||||||
|
}
|
||||||
|
for (d, c) in clicks_data {
|
||||||
|
trend_map.insert(d, c);
|
||||||
|
}
|
||||||
|
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||||
|
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||||
|
|
||||||
|
let countries_data = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||||
|
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let countries_chart = generate_bar_chart(&countries_data);
|
||||||
|
|
||||||
|
let referrers_data = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||||
|
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||||
|
|
||||||
|
let browsers_data = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||||
|
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||||
|
|
||||||
|
let template = crate::templates::UserDashboardTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
total_urls,
|
||||||
|
total_pages,
|
||||||
|
total_clicks,
|
||||||
|
active_links,
|
||||||
|
dead_links,
|
||||||
|
traffic_chart,
|
||||||
|
countries_chart,
|
||||||
|
browsers_chart,
|
||||||
|
referrers_chart,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/dashboard
|
||||||
|
pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
let (user, _) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (total_urls, active_links, dead_links) = {
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_pages = {
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
get_landing_page_count(&conn).unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_clicks = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
get_total_clicks(&conn).unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let clicks_data = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
get_clicks_trend(&conn, "url", "all", 30)
|
||||||
|
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut trend_map = std::collections::BTreeMap::new();
|
||||||
|
for i in (0..30).rev() {
|
||||||
|
let date_str = (Utc::now() - chrono::Duration::days(i))
|
||||||
|
.format("%Y-%m-%d")
|
||||||
|
.to_string();
|
||||||
|
trend_map.insert(date_str, 0i64);
|
||||||
|
}
|
||||||
|
for (d, c) in clicks_data {
|
||||||
|
trend_map.insert(d, c);
|
||||||
|
}
|
||||||
|
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||||
|
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||||
|
|
||||||
|
let countries_data = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||||
|
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let countries_chart = generate_bar_chart(&countries_data);
|
||||||
|
|
||||||
|
let referrers_data = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||||
|
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||||
|
|
||||||
|
let browsers_data = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||||
|
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||||
|
|
||||||
|
let template = crate::templates::DashboardTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
total_urls,
|
||||||
|
total_pages,
|
||||||
|
total_clicks,
|
||||||
|
active_links,
|
||||||
|
dead_links,
|
||||||
|
traffic_chart,
|
||||||
|
countries_chart,
|
||||||
|
browsers_chart,
|
||||||
|
referrers_chart,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct JobHistoryRow {
|
||||||
|
pub id: String,
|
||||||
|
pub job_name: String,
|
||||||
|
pub status: String,
|
||||||
|
pub started_at: String,
|
||||||
|
pub finished_at: Option<String>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct HealthCheckRow {
|
||||||
|
pub id: String,
|
||||||
|
pub object_type: String,
|
||||||
|
pub object_id: String,
|
||||||
|
pub checked_at: String,
|
||||||
|
pub status_code: Option<i64>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub is_healthy: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/health
|
||||||
|
pub async fn health_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut db_reports = vec![];
|
||||||
|
if let Ok(r) =
|
||||||
|
crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin")
|
||||||
|
{
|
||||||
|
db_reports.push(r);
|
||||||
|
}
|
||||||
|
if let Ok(r) =
|
||||||
|
crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system")
|
||||||
|
{
|
||||||
|
db_reports.push(r);
|
||||||
|
}
|
||||||
|
if let Ok(r) =
|
||||||
|
crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users")
|
||||||
|
{
|
||||||
|
db_reports.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_db_path = state.config.data_dir.join("admin").join("system.db");
|
||||||
|
let users_db_path = state.config.data_dir.join("admin").join("users.db");
|
||||||
|
let admin_db_path = state.config.data_dir.join("admin").join("admin.db");
|
||||||
|
|
||||||
|
let system_db_size = format_size(
|
||||||
|
std::fs::metadata(&system_db_path)
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0),
|
||||||
|
);
|
||||||
|
let users_db_size = format_size(
|
||||||
|
std::fs::metadata(&users_db_path)
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0),
|
||||||
|
);
|
||||||
|
let admin_db_size = format_size(
|
||||||
|
std::fs::metadata(&admin_db_path)
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let users_dir = state.config.data_dir.join("users");
|
||||||
|
let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0));
|
||||||
|
let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0));
|
||||||
|
|
||||||
|
let job_history = {
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(JobHistoryRow {
|
||||||
|
id: row.get(0)?,
|
||||||
|
job_name: row.get(1)?,
|
||||||
|
status: row.get(2)?,
|
||||||
|
started_at: row.get(3)?,
|
||||||
|
finished_at: row.get(4)?,
|
||||||
|
error_message: row.get(5)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let health_checks = {
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(HealthCheckRow {
|
||||||
|
id: row.get(0)?,
|
||||||
|
object_type: row.get(1)?,
|
||||||
|
object_id: row.get(2)?,
|
||||||
|
checked_at: row.get(3)?,
|
||||||
|
status_code: row.get(4)?,
|
||||||
|
error_message: row.get(5)?,
|
||||||
|
is_healthy: row.get(6)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let (registry_errors, registry_warnings) = {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
match crate::services::registry_validator::RegistryValidator::scan(
|
||||||
|
&system_conn,
|
||||||
|
&users_conn,
|
||||||
|
&state.config.data_dir,
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Ok(issues) => {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
for issue in issues {
|
||||||
|
use crate::services::registry_validator::RegistryIssueType;
|
||||||
|
match issue.issue_type {
|
||||||
|
RegistryIssueType::StaleReservation
|
||||||
|
| RegistryIssueType::TenantAdminHasIsolatedContent => {
|
||||||
|
warnings.push(format!(
|
||||||
|
"Warning for slug {}: {}",
|
||||||
|
issue.slug, issue.description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
errors.push(format!(
|
||||||
|
"Error for slug {}: {}",
|
||||||
|
issue.slug, issue.description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(errors, warnings)
|
||||||
|
}
|
||||||
|
Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let template = crate::templates::HealthTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
db_reports,
|
||||||
|
total_data_size,
|
||||||
|
system_db_size,
|
||||||
|
users_db_size,
|
||||||
|
admin_db_size,
|
||||||
|
tenants_db_size,
|
||||||
|
job_history,
|
||||||
|
health_checks,
|
||||||
|
registry_errors,
|
||||||
|
registry_warnings,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,849 @@
|
|||||||
|
//! Admin web UI handlers (feature-split modules).
|
||||||
|
//!
|
||||||
|
//! Handlers are organized by domain; shared auth, audit, export, and helpers
|
||||||
|
//! live in this module root so child modules can access them via `super`.
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token,
|
||||||
|
hash_password, verify_csrf, verify_password, verify_sha256,
|
||||||
|
};
|
||||||
|
use crate::charts::{generate_bar_chart, generate_line_chart};
|
||||||
|
use crate::db::admin::{
|
||||||
|
create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config,
|
||||||
|
write_audit_log as write_audit_log_legacy,
|
||||||
|
};
|
||||||
|
use crate::db::analytics::{
|
||||||
|
clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings,
|
||||||
|
get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors,
|
||||||
|
get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated,
|
||||||
|
get_total_clicks, get_visits_schema_columns, parse_ua,
|
||||||
|
};
|
||||||
|
use crate::db::content::{
|
||||||
|
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id,
|
||||||
|
get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts,
|
||||||
|
list_landing_pages, list_urls,
|
||||||
|
};
|
||||||
|
use crate::models::User;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage};
|
||||||
|
use axum::{
|
||||||
|
extract::{ConnectInfo, Path, Query, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
Form,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::Cookie;
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
use chrono::Utc;
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use rusqlite::{params, OptionalExtension};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use tar::Builder;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// --- Shared constants, auth, audit, and export helpers ---
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn write_audit_log(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
state: &AppState,
|
||||||
|
username: &str,
|
||||||
|
action: &str,
|
||||||
|
object_type: Option<&str>,
|
||||||
|
object_id: Option<&str>,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
) -> rusqlite::Result<crate::models::AuditLog> {
|
||||||
|
let res = write_audit_log_legacy(
|
||||||
|
conn,
|
||||||
|
username,
|
||||||
|
action,
|
||||||
|
object_type,
|
||||||
|
object_id,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also write to unified audit events in system.db
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent);
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
username,
|
||||||
|
action,
|
||||||
|
object_type.unwrap_or(""),
|
||||||
|
object_id.unwrap_or(""),
|
||||||
|
Some(&metadata),
|
||||||
|
);
|
||||||
|
|
||||||
|
res
|
||||||
|
}
|
||||||
|
pub(crate) const PAGE_SIZE: usize = 25;
|
||||||
|
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
|
||||||
|
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
|
||||||
|
// Helper: Verify admin session and return user or redirect to login
|
||||||
|
pub(crate) async fn require_auth(
|
||||||
|
state: &AppState,
|
||||||
|
jar: &CookieJar,
|
||||||
|
) -> Result<(User, String), Redirect> {
|
||||||
|
let conn = match state.users_db.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Err(Redirect::to("/admin/login")),
|
||||||
|
};
|
||||||
|
match authenticate_admin_session(&conn, jar) {
|
||||||
|
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||||
|
_ => Err(Redirect::to("/admin/login")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Helper: Verify tenant user session and return user or redirect to login
|
||||||
|
pub(crate) async fn require_user_auth(
|
||||||
|
state: &AppState,
|
||||||
|
jar: &CookieJar,
|
||||||
|
) -> Result<(crate::models::TenantUser, String), Redirect> {
|
||||||
|
let conn = match state.users_db.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Err(Redirect::to("/login")),
|
||||||
|
};
|
||||||
|
match authenticate_user_session(&conn, jar) {
|
||||||
|
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||||
|
_ => Err(Redirect::to("/login")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AnalyticsQuery {
|
||||||
|
pub analytics_page: Option<usize>,
|
||||||
|
pub date_from: Option<String>,
|
||||||
|
pub date_to: Option<String>,
|
||||||
|
}
|
||||||
|
pub(crate) fn validate_date_filters(
|
||||||
|
date_from: Option<&str>,
|
||||||
|
date_to: Option<&str>,
|
||||||
|
) -> Result<(Option<String>, Option<String>), StatusCode> {
|
||||||
|
let from_parsed = match date_from {
|
||||||
|
Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") {
|
||||||
|
Ok(d) => Some(d),
|
||||||
|
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let to_parsed = match date_to {
|
||||||
|
Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||||
|
Ok(d) => Some(d),
|
||||||
|
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let (Some(f), Some(t)) = (from_parsed, to_parsed) {
|
||||||
|
if f > t {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
from_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||||
|
to_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(crate) fn escape_csv_field(field: &str) -> String {
|
||||||
|
let needs_escaping =
|
||||||
|
field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r');
|
||||||
|
if needs_escaping {
|
||||||
|
let escaped = field.replace('"', "\"\"");
|
||||||
|
format!("\"{}\"", escaped)
|
||||||
|
} else {
|
||||||
|
field.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) struct DbExportStream {
|
||||||
|
receiver: tokio::sync::mpsc::Receiver<Result<axum::body::Bytes, std::convert::Infallible>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl futures_util::stream::Stream for DbExportStream {
|
||||||
|
type Item = Result<axum::body::Bytes, std::convert::Infallible>;
|
||||||
|
|
||||||
|
fn poll_next(
|
||||||
|
mut self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
) -> std::task::Poll<Option<Self::Item>> {
|
||||||
|
self.receiver.poll_recv(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn perform_csv_export(
|
||||||
|
state: AppState,
|
||||||
|
target_type: &'static str,
|
||||||
|
id: String,
|
||||||
|
date_from: Option<String>,
|
||||||
|
date_to: Option<String>,
|
||||||
|
) -> Response {
|
||||||
|
let (clean_date_from, clean_date_to) =
|
||||||
|
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(status) => return status.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_exists = {
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
if target_type == "url" {
|
||||||
|
get_url_by_id(&conn, &id)
|
||||||
|
.map(|u| u.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
get_landing_page_by_id(&conn, &id)
|
||||||
|
.map(|p| p.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !target_exists {
|
||||||
|
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
get_target_visit_total_filtered(
|
||||||
|
&conn,
|
||||||
|
target_type,
|
||||||
|
&id,
|
||||||
|
clean_date_from.as_deref(),
|
||||||
|
clean_date_to.as_deref(),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (has_utm_source, has_utm_campaign) = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||||
|
(cols.contains("utm_source"), cols.contains("utm_campaign"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx, rx) =
|
||||||
|
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
|
||||||
|
let analytics_db = state.analytics_db.clone();
|
||||||
|
let target_id = id.clone();
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let conn = analytics_db.lock().unwrap();
|
||||||
|
|
||||||
|
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
|
||||||
|
if has_utm_source {
|
||||||
|
header.push_str(",UTM Source");
|
||||||
|
}
|
||||||
|
if has_utm_campaign {
|
||||||
|
header.push_str(",UTM Campaign");
|
||||||
|
}
|
||||||
|
header.push('\n');
|
||||||
|
|
||||||
|
if tx
|
||||||
|
.blocking_send(Ok(axum::body::Bytes::from(header)))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let select_fields = if has_utm_source && has_utm_campaign {
|
||||||
|
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
|
||||||
|
} else if has_utm_source {
|
||||||
|
"timestamp, ip_address, country, referer, user_agent, utm_source"
|
||||||
|
} else if has_utm_campaign {
|
||||||
|
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
|
||||||
|
} else {
|
||||||
|
"timestamp, ip_address, country, referer, user_agent"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sql = format!(
|
||||||
|
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
|
||||||
|
select_fields
|
||||||
|
);
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
|
||||||
|
vec![Box::new(target_type.to_string()), Box::new(target_id)];
|
||||||
|
|
||||||
|
if let Some(df) = clean_date_from.as_deref() {
|
||||||
|
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
|
||||||
|
params.push(Box::new(format!("{}T00:00:00Z", df)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dt) = clean_date_to.as_deref() {
|
||||||
|
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||||
|
let next_day = parsed_date + chrono::Duration::days(1);
|
||||||
|
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
|
||||||
|
params.push(Box::new(format!(
|
||||||
|
"{}T00:00:00Z",
|
||||||
|
next_day.format("%Y-%m-%d")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.push_str(" ORDER BY timestamp DESC, id DESC");
|
||||||
|
|
||||||
|
let mut stmt = match conn.prepare(&sql) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut csv_buffer = String::new();
|
||||||
|
|
||||||
|
while let Ok(Some(row)) = rows.next() {
|
||||||
|
let timestamp: String = row.get(0).unwrap_or_default();
|
||||||
|
let ip_address: String = row.get(1).unwrap_or_default();
|
||||||
|
let country: String = row.get(2).unwrap_or_default();
|
||||||
|
let referer: String = row.get(3).unwrap_or_default();
|
||||||
|
let user_agent: String = row.get(4).unwrap_or_default();
|
||||||
|
|
||||||
|
let (browser, _, _) = parse_ua(&user_agent);
|
||||||
|
let referrer = clean_referrer(&referer);
|
||||||
|
let country_display = if country.is_empty() {
|
||||||
|
"Unknown".to_string()
|
||||||
|
} else {
|
||||||
|
country
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut line = format!(
|
||||||
|
"{},{},{},{},{},{}",
|
||||||
|
escape_csv_field(×tamp),
|
||||||
|
escape_csv_field(&ip_address),
|
||||||
|
escape_csv_field(&country_display),
|
||||||
|
escape_csv_field(&referrer),
|
||||||
|
escape_csv_field(&browser),
|
||||||
|
escape_csv_field(&user_agent)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut col_idx = 5;
|
||||||
|
if has_utm_source {
|
||||||
|
let utm_src: String = row.get(col_idx).unwrap_or_default();
|
||||||
|
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
|
||||||
|
col_idx += 1;
|
||||||
|
}
|
||||||
|
if has_utm_campaign {
|
||||||
|
let utm_camp: String = row.get(col_idx).unwrap_or_default();
|
||||||
|
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
|
||||||
|
}
|
||||||
|
line.push('\n');
|
||||||
|
|
||||||
|
csv_buffer.push_str(&line);
|
||||||
|
if csv_buffer.len() >= 8192 {
|
||||||
|
let bytes = axum::body::Bytes::from(csv_buffer);
|
||||||
|
if tx.blocking_send(Ok(bytes)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
csv_buffer = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !csv_buffer.is_empty() {
|
||||||
|
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = DbExportStream { receiver: rx };
|
||||||
|
let filename = if target_type == "url" {
|
||||||
|
format!("url_{}_analytics.csv", id)
|
||||||
|
} else {
|
||||||
|
format!("page_{}_analytics.csv", id)
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("Content-Type", "text/csv"),
|
||||||
|
(
|
||||||
|
"Content-Disposition",
|
||||||
|
&format!("attachment; filename=\"{}\"", filename),
|
||||||
|
),
|
||||||
|
("X-BZOD-Export-Records", &count.to_string()),
|
||||||
|
],
|
||||||
|
axum::body::Body::from_stream(stream),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
pub(crate) async fn perform_json_export(
|
||||||
|
state: AppState,
|
||||||
|
target_type: &'static str,
|
||||||
|
id: String,
|
||||||
|
date_from: Option<String>,
|
||||||
|
date_to: Option<String>,
|
||||||
|
) -> Response {
|
||||||
|
let (clean_date_from, clean_date_to) =
|
||||||
|
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(status) => return status.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_exists = {
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
if target_type == "url" {
|
||||||
|
get_url_by_id(&conn, &id)
|
||||||
|
.map(|u| u.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
get_landing_page_by_id(&conn, &id)
|
||||||
|
.map(|p| p.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !target_exists {
|
||||||
|
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
get_target_visit_total_filtered(
|
||||||
|
&conn,
|
||||||
|
target_type,
|
||||||
|
&id,
|
||||||
|
clean_date_from.as_deref(),
|
||||||
|
clean_date_to.as_deref(),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
if count > MAX_JSON_EXPORT_ROWS as i64 {
|
||||||
|
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let visits_raw = {
|
||||||
|
let conn = state.analytics_db.lock().unwrap();
|
||||||
|
match get_target_visits_all_in_memory(
|
||||||
|
&conn,
|
||||||
|
target_type,
|
||||||
|
&id,
|
||||||
|
clean_date_from.as_deref(),
|
||||||
|
clean_date_to.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct JsonExportRow {
|
||||||
|
timestamp: String,
|
||||||
|
ip_address: String,
|
||||||
|
country: String,
|
||||||
|
referrer: String,
|
||||||
|
browser: String,
|
||||||
|
user_agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let export_rows: Vec<JsonExportRow> = visits_raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let (browser, _, _) = parse_ua(&r.user_agent);
|
||||||
|
let referrer = clean_referrer(&r.referer);
|
||||||
|
let country_display = if r.country.is_empty() {
|
||||||
|
"Unknown".to_string()
|
||||||
|
} else {
|
||||||
|
r.country
|
||||||
|
};
|
||||||
|
JsonExportRow {
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
ip_address: r.ip_address,
|
||||||
|
country: country_display,
|
||||||
|
referrer,
|
||||||
|
browser,
|
||||||
|
user_agent: r.user_agent,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let body_str = match serde_json::to_string(&export_rows) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = if target_type == "url" {
|
||||||
|
format!("url_{}_analytics.json", id)
|
||||||
|
} else {
|
||||||
|
format!("page_{}_analytics.json", id)
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("Content-Type", "application/json"),
|
||||||
|
(
|
||||||
|
"Content-Disposition",
|
||||||
|
&format!("attachment; filename=\"{}\"", filename),
|
||||||
|
),
|
||||||
|
("X-BZOD-Export-Records", &count.to_string()),
|
||||||
|
],
|
||||||
|
body_str,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
// Helpers
|
||||||
|
pub(crate) fn format_size(bytes: u64) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{} B", bytes)
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{:.2} KB", bytes as f64 / 1024.0)
|
||||||
|
} else {
|
||||||
|
format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn get_dir_size(dir: &std::path::Path) -> std::io::Result<u64> {
|
||||||
|
let mut total = 0;
|
||||||
|
if dir.is_dir() {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
total += get_dir_size(&path)?;
|
||||||
|
} else {
|
||||||
|
total += entry.metadata()?.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
pub(crate) async fn perform_user_csv_export(
|
||||||
|
user_dbs: crate::state::UserDbs,
|
||||||
|
target_type: &'static str,
|
||||||
|
id: String,
|
||||||
|
date_from: Option<String>,
|
||||||
|
date_to: Option<String>,
|
||||||
|
) -> Response {
|
||||||
|
let (clean_date_from, clean_date_to) =
|
||||||
|
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(status) => return status.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_exists = {
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
if target_type == "url" {
|
||||||
|
get_url_by_id(&conn, &id)
|
||||||
|
.map(|u| u.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
get_landing_page_by_id(&conn, &id)
|
||||||
|
.map(|p| p.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !target_exists {
|
||||||
|
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
get_target_visit_total_filtered(
|
||||||
|
&conn,
|
||||||
|
target_type,
|
||||||
|
&id,
|
||||||
|
clean_date_from.as_deref(),
|
||||||
|
clean_date_to.as_deref(),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (has_utm_source, has_utm_campaign) = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||||
|
(cols.contains("utm_source"), cols.contains("utm_campaign"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let (tx, rx) =
|
||||||
|
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
|
||||||
|
let analytics_db = user_dbs.analytics.clone();
|
||||||
|
let target_id = id.clone();
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let conn = analytics_db.lock().unwrap();
|
||||||
|
|
||||||
|
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
|
||||||
|
if has_utm_source {
|
||||||
|
header.push_str(",UTM Source");
|
||||||
|
}
|
||||||
|
if has_utm_campaign {
|
||||||
|
header.push_str(",UTM Campaign");
|
||||||
|
}
|
||||||
|
header.push('\n');
|
||||||
|
|
||||||
|
if tx
|
||||||
|
.blocking_send(Ok(axum::body::Bytes::from(header)))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let select_fields = if has_utm_source && has_utm_campaign {
|
||||||
|
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
|
||||||
|
} else if has_utm_source {
|
||||||
|
"timestamp, ip_address, country, referer, user_agent, utm_source"
|
||||||
|
} else if has_utm_campaign {
|
||||||
|
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
|
||||||
|
} else {
|
||||||
|
"timestamp, ip_address, country, referer, user_agent"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sql = format!(
|
||||||
|
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
|
||||||
|
select_fields
|
||||||
|
);
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
|
||||||
|
vec![Box::new(target_type.to_string()), Box::new(target_id)];
|
||||||
|
|
||||||
|
if let Some(df) = clean_date_from.as_deref() {
|
||||||
|
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
|
||||||
|
params.push(Box::new(format!("{}T00:00:00Z", df)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dt) = clean_date_to.as_deref() {
|
||||||
|
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||||
|
let next_day = parsed_date + chrono::Duration::days(1);
|
||||||
|
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
|
||||||
|
params.push(Box::new(format!(
|
||||||
|
"{}T00:00:00Z",
|
||||||
|
next_day.format("%Y-%m-%d")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.push_str(" ORDER BY timestamp DESC, id DESC");
|
||||||
|
|
||||||
|
let mut stmt = match conn.prepare(&sql) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut csv_buffer = String::new();
|
||||||
|
|
||||||
|
while let Ok(Some(row)) = rows.next() {
|
||||||
|
let timestamp: String = row.get(0).unwrap_or_default();
|
||||||
|
let ip_address: String = row.get(1).unwrap_or_default();
|
||||||
|
let country: String = row.get(2).unwrap_or_default();
|
||||||
|
let referer: String = row.get(3).unwrap_or_default();
|
||||||
|
let user_agent: String = row.get(4).unwrap_or_default();
|
||||||
|
|
||||||
|
let (browser, _, _) = parse_ua(&user_agent);
|
||||||
|
let referrer = clean_referrer(&referer);
|
||||||
|
let country_display = if country.is_empty() {
|
||||||
|
"Unknown".to_string()
|
||||||
|
} else {
|
||||||
|
country
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut line = format!(
|
||||||
|
"{},{},{},{},{},{}",
|
||||||
|
escape_csv_field(×tamp),
|
||||||
|
escape_csv_field(&ip_address),
|
||||||
|
escape_csv_field(&country_display),
|
||||||
|
escape_csv_field(&referrer),
|
||||||
|
escape_csv_field(&browser),
|
||||||
|
escape_csv_field(&user_agent)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut col_idx = 5;
|
||||||
|
if has_utm_source {
|
||||||
|
let utm_src: String = row.get(col_idx).unwrap_or_default();
|
||||||
|
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
|
||||||
|
col_idx += 1;
|
||||||
|
}
|
||||||
|
if has_utm_campaign {
|
||||||
|
let utm_camp: String = row.get(col_idx).unwrap_or_default();
|
||||||
|
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
|
||||||
|
}
|
||||||
|
line.push('\n');
|
||||||
|
|
||||||
|
csv_buffer.push_str(&line);
|
||||||
|
if csv_buffer.len() >= 8192 {
|
||||||
|
let bytes = axum::body::Bytes::from(csv_buffer);
|
||||||
|
if tx.blocking_send(Ok(bytes)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
csv_buffer = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !csv_buffer.is_empty() {
|
||||||
|
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = DbExportStream { receiver: rx };
|
||||||
|
let filename = if target_type == "url" {
|
||||||
|
format!("url_{}_analytics.csv", id)
|
||||||
|
} else {
|
||||||
|
format!("page_{}_analytics.csv", id)
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("Content-Type", "text/csv"),
|
||||||
|
(
|
||||||
|
"Content-Disposition",
|
||||||
|
&format!("attachment; filename=\"{}\"", filename),
|
||||||
|
),
|
||||||
|
("X-BZOD-Export-Records", &count.to_string()),
|
||||||
|
],
|
||||||
|
axum::body::Body::from_stream(stream),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
pub(crate) async fn perform_user_json_export(
|
||||||
|
user_dbs: crate::state::UserDbs,
|
||||||
|
target_type: &'static str,
|
||||||
|
id: String,
|
||||||
|
date_from: Option<String>,
|
||||||
|
date_to: Option<String>,
|
||||||
|
) -> Response {
|
||||||
|
let (clean_date_from, clean_date_to) =
|
||||||
|
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(status) => return status.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_exists = {
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
if target_type == "url" {
|
||||||
|
get_url_by_id(&conn, &id)
|
||||||
|
.map(|u| u.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
get_landing_page_by_id(&conn, &id)
|
||||||
|
.map(|p| p.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !target_exists {
|
||||||
|
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
get_target_visit_total_filtered(
|
||||||
|
&conn,
|
||||||
|
target_type,
|
||||||
|
&id,
|
||||||
|
clean_date_from.as_deref(),
|
||||||
|
clean_date_to.as_deref(),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
if count > MAX_JSON_EXPORT_ROWS as i64 {
|
||||||
|
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let visits_raw = {
|
||||||
|
let conn = user_dbs.analytics.lock().unwrap();
|
||||||
|
match get_target_visits_all_in_memory(
|
||||||
|
&conn,
|
||||||
|
target_type,
|
||||||
|
&id,
|
||||||
|
clean_date_from.as_deref(),
|
||||||
|
clean_date_to.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct JsonExportRow {
|
||||||
|
timestamp: String,
|
||||||
|
ip_address: String,
|
||||||
|
country: String,
|
||||||
|
referrer: String,
|
||||||
|
browser: String,
|
||||||
|
user_agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let export_rows: Vec<JsonExportRow> = visits_raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let (browser, _, _) = parse_ua(&r.user_agent);
|
||||||
|
let referrer = clean_referrer(&r.referer);
|
||||||
|
let country_display = if r.country.is_empty() {
|
||||||
|
"Unknown".to_string()
|
||||||
|
} else {
|
||||||
|
r.country
|
||||||
|
};
|
||||||
|
JsonExportRow {
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
ip_address: r.ip_address,
|
||||||
|
country: country_display,
|
||||||
|
referrer,
|
||||||
|
browser,
|
||||||
|
user_agent: r.user_agent,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let body_str = match serde_json::to_string(&export_rows) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = if target_type == "url" {
|
||||||
|
format!("url_{}_analytics.json", id)
|
||||||
|
} else {
|
||||||
|
format!("page_{}_analytics.json", id)
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("Content-Type", "application/json"),
|
||||||
|
(
|
||||||
|
"Content-Disposition",
|
||||||
|
&format!("attachment; filename=\"{}\"", filename),
|
||||||
|
),
|
||||||
|
("X-BZOD-Export-Records", &count.to_string()),
|
||||||
|
],
|
||||||
|
body_str,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Feature modules ---
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
pub use auth::*;
|
||||||
|
mod dashboard;
|
||||||
|
pub use dashboard::*;
|
||||||
|
mod urls;
|
||||||
|
pub use urls::*;
|
||||||
|
mod pages;
|
||||||
|
pub use pages::*;
|
||||||
|
mod users;
|
||||||
|
pub use users::*;
|
||||||
|
mod analytics;
|
||||||
|
pub use analytics::*;
|
||||||
|
mod settings;
|
||||||
|
pub use settings::*;
|
||||||
|
mod audit;
|
||||||
|
pub use audit::*;
|
||||||
|
mod sessions;
|
||||||
|
pub use sessions::*;
|
||||||
|
mod quotas;
|
||||||
|
pub use quotas::*;
|
||||||
|
mod health;
|
||||||
|
pub use health::*;
|
||||||
|
mod backups;
|
||||||
|
pub use backups::*;
|
||||||
|
mod api_keys;
|
||||||
|
pub use api_keys::*;
|
||||||
|
mod moderation;
|
||||||
|
pub use moderation::*;
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct GlobalSlugRow {
|
||||||
|
pub slug: String,
|
||||||
|
pub owner_user_id: i64,
|
||||||
|
pub target_type: String,
|
||||||
|
pub target_id: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub status: String,
|
||||||
|
pub deleted_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ModerationLogEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub admin_username: String,
|
||||||
|
pub target_user_id: i64,
|
||||||
|
pub target_username: Option<String>,
|
||||||
|
pub resource_type: String,
|
||||||
|
pub resource_identifier: String,
|
||||||
|
pub action: String,
|
||||||
|
pub severity: String,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct SlugHistoryRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub slug: String,
|
||||||
|
pub old_owner_user_id: Option<i64>,
|
||||||
|
pub new_owner_user_id: Option<i64>,
|
||||||
|
pub action: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub admin_username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/moderation
|
||||||
|
pub async fn moderation_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let flagged_items = {
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \
|
||||||
|
FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||||
|
).unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(GlobalSlugRow {
|
||||||
|
slug: row.get(0)?,
|
||||||
|
owner_user_id: row.get(1)?,
|
||||||
|
target_type: row.get(2)?,
|
||||||
|
target_id: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
updated_at: row.get(5)?,
|
||||||
|
status: row.get(6)?,
|
||||||
|
deleted_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let logs = {
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason \
|
||||||
|
FROM moderation_events ORDER BY timestamp DESC LIMIT 50;"
|
||||||
|
).unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(ModerationLogEntry {
|
||||||
|
id: row.get(0)?,
|
||||||
|
timestamp: row.get(1)?,
|
||||||
|
admin_username: row.get(2)?,
|
||||||
|
target_user_id: row.get(3)?,
|
||||||
|
target_username: row.get(4)?,
|
||||||
|
resource_type: row.get(5)?,
|
||||||
|
resource_identifier: row.get(6)?,
|
||||||
|
action: row.get(7)?,
|
||||||
|
severity: row.get(8)?,
|
||||||
|
reason: row.get(9)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::ModerationTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
flagged_items,
|
||||||
|
logs,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AdminModerateForm {
|
||||||
|
pub slug: String,
|
||||||
|
pub action: String,
|
||||||
|
pub severity: String,
|
||||||
|
pub reason: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/moderation
|
||||||
|
pub async fn moderation_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<AdminModerateForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = form.action.trim().to_lowercase();
|
||||||
|
if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) {
|
||||||
|
return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (owner_user_id, target_type) = {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let row_opt: Option<(i64, String)> = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
|
||||||
|
[&form.slug],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
match row_opt {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let owner_username = {
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
|
||||||
|
.unwrap_or(None)
|
||||||
|
.map(|u| u.username)
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
if action == "deleted" {
|
||||||
|
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||||
|
} else {
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||||
|
rusqlite::params![action, now, form.slug],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_id = Uuid::new_v4().to_string();
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
|
||||||
|
rusqlite::params![
|
||||||
|
event_id,
|
||||||
|
now,
|
||||||
|
user.username,
|
||||||
|
owner_user_id,
|
||||||
|
owner_username,
|
||||||
|
target_type,
|
||||||
|
form.slug,
|
||||||
|
action,
|
||||||
|
form.severity,
|
||||||
|
form.reason
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"CONTENT_MODERATION",
|
||||||
|
"slug",
|
||||||
|
&form.slug,
|
||||||
|
Some(&format!("Action: {}, Reason: {}", action, form.reason)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/admin/moderation?success=Moderation action '{}' applied",
|
||||||
|
action
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SlugsQuery {
|
||||||
|
pub search: Option<String>,
|
||||||
|
pub owner: Option<i64>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub success: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/slugs
|
||||||
|
pub async fn slugs_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(query): Query<SlugsQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
|
||||||
|
let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string();
|
||||||
|
let mut values = vec![];
|
||||||
|
if let Some(ref s) = query.search {
|
||||||
|
if !s.trim().is_empty() {
|
||||||
|
sql.push_str(" AND slug LIKE ?");
|
||||||
|
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(o) = query.owner {
|
||||||
|
sql.push_str(" AND owner_user_id = ?");
|
||||||
|
values.push(rusqlite::types::Value::Integer(o));
|
||||||
|
}
|
||||||
|
if let Some(ref st) = query.status {
|
||||||
|
if !st.trim().is_empty() {
|
||||||
|
sql.push_str(" AND status = ?");
|
||||||
|
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||||
|
|
||||||
|
let slugs = {
|
||||||
|
let mut stmt = system_conn.prepare(&sql).unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||||
|
Ok(GlobalSlugRow {
|
||||||
|
slug: row.get(0)?,
|
||||||
|
owner_user_id: row.get(1)?,
|
||||||
|
target_type: row.get(2)?,
|
||||||
|
target_id: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
updated_at: row.get(5)?,
|
||||||
|
status: row.get(6)?,
|
||||||
|
deleted_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let history = {
|
||||||
|
let mut stmt = system_conn.prepare(
|
||||||
|
"SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \
|
||||||
|
FROM slug_history ORDER BY timestamp DESC LIMIT 50;"
|
||||||
|
).unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(SlugHistoryRow {
|
||||||
|
id: row.get(0)?,
|
||||||
|
slug: row.get(1)?,
|
||||||
|
old_owner_user_id: row.get(2)?,
|
||||||
|
new_owner_user_id: row.get(3)?,
|
||||||
|
action: row.get(4)?,
|
||||||
|
timestamp: row.get(5)?,
|
||||||
|
admin_username: row.get(6)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::SlugsTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
slugs,
|
||||||
|
history,
|
||||||
|
csrf_token,
|
||||||
|
search_filter: query.search,
|
||||||
|
owner_filter: query.owner,
|
||||||
|
status_filter: query.status,
|
||||||
|
success: query.success,
|
||||||
|
error: query.error,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AdminTransferForm {
|
||||||
|
pub slug: String,
|
||||||
|
pub new_owner_user_id: i64,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/slugs/transfer
|
||||||
|
pub async fn slugs_transfer_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<AdminTransferForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_exists = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||||
|
[form.new_owner_user_id],
|
||||||
|
|row| row.get::<_, bool>(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !user_exists {
|
||||||
|
return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (old_owner, target_type, mut new_target_id) =
|
||||||
|
{
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let row_opt: Option<(i64, String, String)> = conn.query_row(
|
||||||
|
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||||
|
[&form.slug],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||||
|
).optional().unwrap_or(None);
|
||||||
|
match row_opt {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if old_owner == form.new_owner_user_id {
|
||||||
|
return Redirect::to("/admin/slugs?error=Slug is already owned by this user")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_dbs = match state.get_user_dbs(old_owner) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/admin/slugs?error=Failed to load current owner's database")
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_dbs = match state.get_user_dbs(form.new_owner_user_id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/admin/slugs?error=Failed to load new owner's database")
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let old_conn = old_dbs.content.lock().unwrap();
|
||||||
|
let new_conn = new_dbs.content.lock().unwrap();
|
||||||
|
|
||||||
|
if target_type == "url" {
|
||||||
|
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
return Redirect::to(&format!(
|
||||||
|
"/admin/slugs?error=Failed to retrieve old URL: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(url) = url_opt {
|
||||||
|
// Check quota
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
let quota_opt =
|
||||||
|
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||||
|
.unwrap_or(None);
|
||||||
|
if let Some(quota) = quota_opt {
|
||||||
|
if quota.current_urls >= quota.max_urls {
|
||||||
|
return Redirect::to(
|
||||||
|
"/admin/slugs?error=New owner has exceeded URL quota limit",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy
|
||||||
|
let new_url = match crate::db::content::create_url_extended(
|
||||||
|
&new_conn,
|
||||||
|
&url.code,
|
||||||
|
&url.destination,
|
||||||
|
url.title.as_deref(),
|
||||||
|
url.description.as_deref(),
|
||||||
|
&url.tags,
|
||||||
|
url.expires_at.as_deref(),
|
||||||
|
url.password_hash.as_deref(),
|
||||||
|
url.max_access_count,
|
||||||
|
) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
return Redirect::to(&format!(
|
||||||
|
"/admin/slugs?error=Failed to copy URL record: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
new_target_id = new_url.id;
|
||||||
|
|
||||||
|
// Delete old
|
||||||
|
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||||
|
}
|
||||||
|
} else if target_type == "page" {
|
||||||
|
let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug)
|
||||||
|
{
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
return Redirect::to(&format!(
|
||||||
|
"/admin/slugs?error=Failed to retrieve old page: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(page) = page_opt {
|
||||||
|
// Check quota
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
let quota_opt =
|
||||||
|
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||||
|
.unwrap_or(None);
|
||||||
|
if let Some(quota) = quota_opt {
|
||||||
|
if quota.current_landings >= quota.max_landings {
|
||||||
|
return Redirect::to(
|
||||||
|
"/admin/slugs?error=New owner has exceeded landing page quota limit",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy
|
||||||
|
let new_page = match crate::db::content::create_landing_page(
|
||||||
|
&new_conn,
|
||||||
|
&page.code,
|
||||||
|
&page.slug,
|
||||||
|
&page.title,
|
||||||
|
&page.html_content,
|
||||||
|
&page.state,
|
||||||
|
) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
return Redirect::to(&format!(
|
||||||
|
"/admin/slugs?error=Failed to copy page record: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
new_target_id = new_page.id;
|
||||||
|
|
||||||
|
// Delete old
|
||||||
|
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||||
|
rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug],
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = conn.execute(
|
||||||
|
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||||
|
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||||
|
rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username],
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&conn,
|
||||||
|
&user.username,
|
||||||
|
"SLUG_TRANSFER",
|
||||||
|
"slug",
|
||||||
|
&form.slug,
|
||||||
|
Some(&format!(
|
||||||
|
"Transferred from {} to {}",
|
||||||
|
old_owner, form.new_owner_user_id
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||||
|
form.slug, form.new_owner_user_id
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AdminSlugStatusForm {
|
||||||
|
pub slug: String,
|
||||||
|
pub status: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/slugs/status
|
||||||
|
pub async fn slugs_status_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<AdminSlugStatusForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = form.status.trim().to_lowercase();
|
||||||
|
if !["active", "flagged", "disabled"].contains(&status.as_str()) {
|
||||||
|
return Redirect::to("/admin/slugs?error=Invalid status").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||||
|
rusqlite::params![status, now, form.slug],
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&conn,
|
||||||
|
&user.username,
|
||||||
|
"SLUG_STATUS_UPDATE",
|
||||||
|
"slug",
|
||||||
|
&form.slug,
|
||||||
|
Some(&format!("Status set to {}", status)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/admin/slugs?success=Slug /{} status updated to {}",
|
||||||
|
form.slug, status
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AdminSlugDeleteForm {
|
||||||
|
pub slug: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/slugs/delete
|
||||||
|
pub async fn slugs_delete_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<AdminSlugDeleteForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let old_owner_user_id: Option<i64> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||||
|
[&form.slug],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||||
|
|
||||||
|
let _ = conn.execute(
|
||||||
|
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||||
|
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||||
|
rusqlite::params![form.slug, old_owner_user_id, now, user.username],
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&conn,
|
||||||
|
&user.username,
|
||||||
|
"SLUG_RELEASE",
|
||||||
|
"slug",
|
||||||
|
&form.slug,
|
||||||
|
Some("Slug released/deleted from global index"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/admin/slugs?success=Slug /{} released successfully",
|
||||||
|
form.slug
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserPagesQuery {
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub page: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateUserPageForm {
|
||||||
|
pub title: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub code: String,
|
||||||
|
pub custom_slug: String,
|
||||||
|
pub state: String,
|
||||||
|
pub html_content: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_pages_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(query): Query<UserPagesQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_dbs = match state.get_user_dbs(user.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (pages, total_pages, page, visible_pages) = {
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
let total_records = get_landing_page_count(&conn).unwrap_or(0);
|
||||||
|
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||||
|
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||||
|
let requested_page = query.page.unwrap_or(1);
|
||||||
|
let current_page = if requested_page == 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
requested_page
|
||||||
|
}
|
||||||
|
.clamp(1, total_pages);
|
||||||
|
let offset = (current_page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
|
||||||
|
let start_page = current_page.saturating_sub(3).max(1);
|
||||||
|
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||||
|
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||||
|
(pages, total_pages, current_page, visible_pages)
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::UserPagesTemplate {
|
||||||
|
admin_username: user.username.clone(),
|
||||||
|
username: user.username,
|
||||||
|
pages,
|
||||||
|
csrf_token,
|
||||||
|
error: query.error,
|
||||||
|
current_page: page,
|
||||||
|
total_pages,
|
||||||
|
visible_pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_pages_create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Form(form): Form<CreateUserPageForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_dbs = match state.get_user_dbs(user.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut code = form.custom_slug.trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = form.code.trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = generate_token(2);
|
||||||
|
} else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
} else if !crate::utils::validation::validate_custom_slug(&code) {
|
||||||
|
return Redirect::to(
|
||||||
|
"/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let clean_slug = form.slug.trim().to_lowercase();
|
||||||
|
if clean_slug.is_empty() {
|
||||||
|
return Redirect::to("/user/pages?error=Slug is required").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
if !crate::db::users::check_quota_limit(&users_conn, user.id, "landings").unwrap_or(false) {
|
||||||
|
return Redirect::to("/user/pages?error=Quota limit exceeded").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||||
|
return Redirect::to("/user/pages?error=Short code/slug already exists")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
&code,
|
||||||
|
user.id,
|
||||||
|
"page",
|
||||||
|
"",
|
||||||
|
"reserving",
|
||||||
|
) {
|
||||||
|
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = {
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
create_landing_page(
|
||||||
|
&conn,
|
||||||
|
&code,
|
||||||
|
&clean_slug,
|
||||||
|
&form.title,
|
||||||
|
&form.html_content,
|
||||||
|
&form.state,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(page) => {
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let global_status = if form.state == "published" {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
};
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||||
|
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "landings");
|
||||||
|
}
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&state.admin_db.lock().unwrap(),
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_PAGE_CREATION",
|
||||||
|
Some("page"),
|
||||||
|
Some(&page.id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
Redirect::to("/user/pages").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||||
|
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_pages_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_dbs = match state.get_user_dbs(user.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &csrf_token) {
|
||||||
|
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
match get_landing_page_by_id(&conn, &id) {
|
||||||
|
Ok(Some(page)) => {
|
||||||
|
let _ = crate::db::users::decrement_quota_counter(
|
||||||
|
&state.users_db.lock().unwrap(),
|
||||||
|
user.id,
|
||||||
|
"landings",
|
||||||
|
);
|
||||||
|
match delete_landing_page(&conn, &id) {
|
||||||
|
Ok(_) => {
|
||||||
|
let _ = crate::db::users::release_global_slug(
|
||||||
|
&state.system_db.lock().unwrap(),
|
||||||
|
&page.code,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&state.admin_db.lock().unwrap(),
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_PAGE_DELETION",
|
||||||
|
Some("page"),
|
||||||
|
Some(&id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
Redirect::to("/user/pages").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Redirect::to("/user/pages?error=Page not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/pages
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PagesQuery {
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub page: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pages_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(query): Query<PagesQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (pages, total_pages, page, visible_pages) = {
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
let total_records = get_landing_page_count(&conn).unwrap_or(0);
|
||||||
|
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||||
|
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||||
|
let requested_page = query.page.unwrap_or(1);
|
||||||
|
let current_page = if requested_page == 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
requested_page
|
||||||
|
}
|
||||||
|
.clamp(1, total_pages);
|
||||||
|
let offset = (current_page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
|
||||||
|
|
||||||
|
let start_page = current_page.saturating_sub(3).max(1);
|
||||||
|
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||||
|
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||||
|
|
||||||
|
(pages, total_pages, current_page, visible_pages)
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::PagesTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
pages,
|
||||||
|
csrf_token,
|
||||||
|
error: query.error,
|
||||||
|
current_page: page,
|
||||||
|
total_pages,
|
||||||
|
visible_pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreatePageForm {
|
||||||
|
pub title: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub code: String,
|
||||||
|
pub custom_slug: String,
|
||||||
|
pub state: String,
|
||||||
|
pub html_content: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/pages/create
|
||||||
|
pub async fn pages_create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Form(form): Form<CreatePageForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||||
|
|
||||||
|
// Custom Slug takes priority if provided
|
||||||
|
let mut code = form.custom_slug.trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = form.code.trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = generate_token(2);
|
||||||
|
} else {
|
||||||
|
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Redirect::to(
|
||||||
|
"/admin/pages?error=Custom code must be exactly 4 hex characters",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||||
|
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let clean_slug = form.slug.trim().to_lowercase();
|
||||||
|
if clean_slug.is_empty() {
|
||||||
|
return Redirect::to("/admin/pages?error=Slug is required").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings")
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||||
|
return Redirect::to("/admin/pages?error=Short code already exists").into_response();
|
||||||
|
}
|
||||||
|
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||||
|
if let Err(e) =
|
||||||
|
crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving")
|
||||||
|
{
|
||||||
|
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = {
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
create_landing_page(
|
||||||
|
&conn,
|
||||||
|
&code,
|
||||||
|
&clean_slug,
|
||||||
|
&form.title,
|
||||||
|
&form.html_content,
|
||||||
|
&form.state,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(page) => {
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let global_status = if form.state == "published" {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
};
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||||
|
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = crate::db::users::increment_quota_counter(
|
||||||
|
&users_conn,
|
||||||
|
admin_user_id,
|
||||||
|
"landings",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let conn_admin = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn_admin,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"PAGE_CREATION",
|
||||||
|
Some("page"),
|
||||||
|
Some(&page.id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Redirect::to("/admin/pages").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||||
|
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/pages/delete/:id
|
||||||
|
pub async fn pages_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &csrf_token) {
|
||||||
|
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
match delete_landing_page(&conn, &id) {
|
||||||
|
Ok(_) => {
|
||||||
|
{
|
||||||
|
let conn_admin = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn_admin,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"PAGE_DELETION",
|
||||||
|
Some("page"),
|
||||||
|
Some(&id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Redirect::to("/admin/pages").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// GET /admin/quotas
|
||||||
|
pub async fn quotas_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let quotas = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas ORDER BY user_id ASC;").unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(crate::models::UserQuotas {
|
||||||
|
user_id: row.get(0)?,
|
||||||
|
max_urls: row.get(1)?,
|
||||||
|
max_landings: row.get(2)?,
|
||||||
|
max_api_tokens: row.get(3)?,
|
||||||
|
max_storage_mb: row.get(4)?,
|
||||||
|
current_urls: row.get(5)?,
|
||||||
|
current_landings: row.get(6)?,
|
||||||
|
current_api_tokens: row.get(7)?,
|
||||||
|
current_storage_mb: row.get(8)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::QuotasTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
quotas,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/quotas
|
||||||
|
pub async fn quotas_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = form.get("action").cloned().unwrap_or_default();
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
|
||||||
|
if action == "reconcile_all" {
|
||||||
|
let user_ids: Vec<i64> = {
|
||||||
|
let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap();
|
||||||
|
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for uid in user_ids {
|
||||||
|
if let Ok(user_dbs) = state.get_user_dbs(uid) {
|
||||||
|
let user_content_conn = user_dbs.content.lock().unwrap();
|
||||||
|
let _ =
|
||||||
|
crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"QUOTAS_RECONCILE_ALL",
|
||||||
|
"quota",
|
||||||
|
"all",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
Redirect::to("/admin/quotas?success=All user quotas reconciled successfully")
|
||||||
|
.into_response()
|
||||||
|
} else if action == "reconcile" {
|
||||||
|
let uid_str = form.get("user_id").cloned().unwrap_or_default();
|
||||||
|
let uid = uid_str.parse::<i64>().unwrap_or(0);
|
||||||
|
if let Ok(user_dbs) = state.get_user_dbs(uid) {
|
||||||
|
let user_content_conn = user_dbs.content.lock().unwrap();
|
||||||
|
let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
|
||||||
|
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"QUOTAS_RECONCILE",
|
||||||
|
"quota",
|
||||||
|
&uid.to_string(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/admin/quotas?error=User databases not found").into_response()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Redirect::to("/admin/quotas?error=Invalid action").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// GET /admin/sessions
|
||||||
|
pub async fn sessions_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sessions = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(crate::models::UserSession {
|
||||||
|
id: row.get(0)?,
|
||||||
|
user_id: row.get(1)?,
|
||||||
|
expires_at: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::SessionsTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
sessions,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/sessions/revoke/:id
|
||||||
|
pub async fn sessions_revoke_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn_sys = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&conn_sys,
|
||||||
|
&user.username,
|
||||||
|
"SESSION_REVOKED",
|
||||||
|
"session",
|
||||||
|
&id,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to("/admin/sessions?success=Session revoked").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/sessions/revoke-all
|
||||||
|
pub async fn sessions_revoke_all_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = conn.execute("DELETE FROM sessions;", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn_sys = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&conn_sys,
|
||||||
|
&user.username,
|
||||||
|
"SESSIONS_ALL_REVOKED",
|
||||||
|
"session",
|
||||||
|
"all",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to("/admin/sessions?success=All active sessions revoked").into_response()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,639 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserUrlsQuery {
|
||||||
|
pub tag: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub page: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateUserUrlForm {
|
||||||
|
pub destination: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub code: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_slug: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_access_count: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub utm_source: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub utm_medium: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub utm_campaign: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_urls_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(query): Query<UserUrlsQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_dbs = match state.get_user_dbs(user.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (urls, total_pages, page, visible_pages) = {
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
let total_records = if let Some(tag_str) = query.tag.as_deref() {
|
||||||
|
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
|
||||||
|
};
|
||||||
|
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||||
|
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||||
|
let requested_page = query.page.unwrap_or(1);
|
||||||
|
let current_page = if requested_page == 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
requested_page
|
||||||
|
}
|
||||||
|
.clamp(1, total_pages);
|
||||||
|
let offset = (current_page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let start_page = current_page.saturating_sub(3).max(1);
|
||||||
|
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||||
|
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||||
|
|
||||||
|
(urls, total_pages, current_page, visible_pages)
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let proto = if state.config.cookie_secure {
|
||||||
|
"https"
|
||||||
|
} else {
|
||||||
|
"http"
|
||||||
|
};
|
||||||
|
let base_url = state
|
||||||
|
.config
|
||||||
|
.base_url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
|
||||||
|
|
||||||
|
let template = crate::templates::UserUrlsTemplate {
|
||||||
|
admin_username: user.username.clone(),
|
||||||
|
username: user.username,
|
||||||
|
urls,
|
||||||
|
csrf_token,
|
||||||
|
error: query.error,
|
||||||
|
tag_filter: query.tag,
|
||||||
|
base_url,
|
||||||
|
current_page: page,
|
||||||
|
total_pages,
|
||||||
|
visible_pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_urls_create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Form(form): Form<CreateUserUrlForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_dbs = match state.get_user_dbs(user.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
|
||||||
|
let mut code = form.custom_slug.trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = form.code.trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = generate_token(3);
|
||||||
|
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
} else if !crate::utils::validation::validate_custom_slug(&code) {
|
||||||
|
return Redirect::to(
|
||||||
|
"/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
if !crate::db::users::check_quota_limit(&users_conn, user.id, "urls").unwrap_or(false) {
|
||||||
|
return Redirect::to("/user/urls?error=Quota limit exceeded").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||||
|
return Redirect::to("/user/urls?error=Short code/slug already exists").into_response();
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
&code,
|
||||||
|
user.id,
|
||||||
|
"url",
|
||||||
|
"",
|
||||||
|
"reserving",
|
||||||
|
) {
|
||||||
|
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = match crate::services::urls::prepare_destination(
|
||||||
|
&form.destination,
|
||||||
|
crate::services::urls::UtmParams {
|
||||||
|
source: Some(&form.utm_source),
|
||||||
|
medium: Some(&form.utm_medium),
|
||||||
|
campaign: Some(&form.utm_campaign),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(msg) => {
|
||||||
|
return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
|
||||||
|
|
||||||
|
let password_hash_opt = if form.password.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match hash_password(&form.password) {
|
||||||
|
Ok(h) => Some(h),
|
||||||
|
Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match form.max_access_count.trim().parse::<i64>() {
|
||||||
|
Ok(c) => Some(c),
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/user/urls?error=Invalid max access count").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tags_list: Vec<String> = form
|
||||||
|
.tags
|
||||||
|
.split(',')
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title_opt = if form.title.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(form.title.trim())
|
||||||
|
};
|
||||||
|
let desc_opt = if form.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(form.description.trim())
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = {
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
crate::db::content::create_url_extended(
|
||||||
|
&conn,
|
||||||
|
&code,
|
||||||
|
&dest,
|
||||||
|
title_opt,
|
||||||
|
desc_opt,
|
||||||
|
&tags_list,
|
||||||
|
expires_at_opt.as_deref(),
|
||||||
|
password_hash_opt.as_deref(),
|
||||||
|
max_access_count_opt,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(url) => {
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||||
|
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "urls");
|
||||||
|
}
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&state.admin_db.lock().unwrap(),
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_URL_CREATION",
|
||||||
|
Some("url"),
|
||||||
|
Some(&url.id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
Redirect::to("/user/urls").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||||
|
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_urls_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_dbs = match state.get_user_dbs(user.id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &csrf_token) {
|
||||||
|
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = user_dbs.content.lock().unwrap();
|
||||||
|
match get_url_by_id(&conn, &id) {
|
||||||
|
Ok(Some(url)) => {
|
||||||
|
let _ = crate::db::users::decrement_quota_counter(
|
||||||
|
&state.users_db.lock().unwrap(),
|
||||||
|
user.id,
|
||||||
|
"urls",
|
||||||
|
);
|
||||||
|
match delete_url(&conn, &id) {
|
||||||
|
Ok(_) => {
|
||||||
|
let _ = crate::db::users::release_global_slug(
|
||||||
|
&state.system_db.lock().unwrap(),
|
||||||
|
&url.code,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&state.admin_db.lock().unwrap(),
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_URL_DELETION",
|
||||||
|
Some("url"),
|
||||||
|
Some(&id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
Redirect::to("/user/urls").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Redirect::to("/user/urls?error=Link not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/urls
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UrlsQuery {
|
||||||
|
pub tag: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub page: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn urls_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(query): Query<UrlsQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (urls, total_pages, page, visible_pages) = {
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
let total_records = if let Some(tag_str) = query.tag.as_deref() {
|
||||||
|
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
|
||||||
|
};
|
||||||
|
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||||
|
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||||
|
let requested_page = query.page.unwrap_or(1);
|
||||||
|
let current_page = if requested_page == 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
requested_page
|
||||||
|
}
|
||||||
|
.clamp(1, total_pages);
|
||||||
|
let offset = (current_page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let start_page = current_page.saturating_sub(3).max(1);
|
||||||
|
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||||
|
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||||
|
|
||||||
|
(urls, total_pages, current_page, visible_pages)
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let proto = if state.config.cookie_secure {
|
||||||
|
"https"
|
||||||
|
} else {
|
||||||
|
"http"
|
||||||
|
};
|
||||||
|
let base_url = state
|
||||||
|
.config
|
||||||
|
.base_url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
|
||||||
|
|
||||||
|
let template = crate::templates::UrlsTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
urls,
|
||||||
|
csrf_token,
|
||||||
|
error: query.error,
|
||||||
|
tag_filter: query.tag,
|
||||||
|
base_url,
|
||||||
|
current_page: page,
|
||||||
|
total_pages,
|
||||||
|
visible_pages,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateUrlForm {
|
||||||
|
pub destination: String,
|
||||||
|
pub code: String,
|
||||||
|
pub custom_slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub tags: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
pub password: String,
|
||||||
|
pub max_access_count: String,
|
||||||
|
pub utm_source: String,
|
||||||
|
pub utm_medium: String,
|
||||||
|
pub utm_campaign: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/urls/create
|
||||||
|
pub async fn urls_create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Form(form): Form<CreateUrlForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||||
|
|
||||||
|
// Custom Slug takes priority if provided
|
||||||
|
let mut code = form.custom_slug.trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = form.code.trim().to_lowercase();
|
||||||
|
if code.is_empty() {
|
||||||
|
code = generate_token(3);
|
||||||
|
} else {
|
||||||
|
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Redirect::to(
|
||||||
|
"/admin/urls?error=Custom code must be exactly 6 hex characters",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||||
|
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = match crate::services::urls::prepare_destination(
|
||||||
|
&form.destination,
|
||||||
|
crate::services::urls::UtmParams {
|
||||||
|
source: Some(&form.utm_source),
|
||||||
|
medium: Some(&form.utm_medium),
|
||||||
|
campaign: Some(&form.utm_campaign),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(msg) => {
|
||||||
|
return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
|
||||||
|
|
||||||
|
let password_hash_opt = if form.password.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match hash_password(&form.password) {
|
||||||
|
Ok(h) => Some(h),
|
||||||
|
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match form.max_access_count.trim().parse::<i64>() {
|
||||||
|
Ok(c) => Some(c),
|
||||||
|
Err(_) => {
|
||||||
|
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tags_list: Vec<String> = form
|
||||||
|
.tags
|
||||||
|
.split(',')
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title_opt = if form.title.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(form.title.trim())
|
||||||
|
};
|
||||||
|
let desc_opt = if form.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(form.description.trim())
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||||
|
return Redirect::to("/admin/urls?error=Short code/slug already exists")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||||
|
if let Err(e) =
|
||||||
|
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")
|
||||||
|
{
|
||||||
|
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = {
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
crate::db::content::create_url_extended(
|
||||||
|
&conn,
|
||||||
|
&code,
|
||||||
|
&dest,
|
||||||
|
title_opt,
|
||||||
|
desc_opt,
|
||||||
|
&tags_list,
|
||||||
|
expires_at_opt.as_deref(),
|
||||||
|
password_hash_opt.as_deref(),
|
||||||
|
max_access_count_opt,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(url) => {
|
||||||
|
{
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = system_conn.execute(
|
||||||
|
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||||
|
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let users_conn = state.users_db.lock().unwrap();
|
||||||
|
let _ =
|
||||||
|
crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let conn = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"URL_CREATION",
|
||||||
|
Some("url"),
|
||||||
|
Some(&url.id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Redirect::to("/admin/urls").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||||
|
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/urls/delete/:id
|
||||||
|
pub async fn urls_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &csrf_token) {
|
||||||
|
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
|
||||||
|
let conn = state.content_db.lock().unwrap();
|
||||||
|
match delete_url(&conn, &id) {
|
||||||
|
Ok(_) => {
|
||||||
|
{
|
||||||
|
let conn_admin = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn_admin,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"URL_DELETION",
|
||||||
|
Some("url"),
|
||||||
|
Some(&id),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Redirect::to("/admin/urls").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,704 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UsersQuery {
|
||||||
|
pub success: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateUserForm {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub account_type: String,
|
||||||
|
pub metadata: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateUserStatusForm {
|
||||||
|
pub status: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateUserTypeForm {
|
||||||
|
pub account_type: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResetPasswordForm {
|
||||||
|
pub new_password: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteUserForm {
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn users_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(query): Query<UsersQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let users = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
crate::db::users::list_users(&conn).unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::UsersTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
users,
|
||||||
|
csrf_token,
|
||||||
|
success: query.success,
|
||||||
|
error: query.error,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn users_create_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<CreateUserForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = form.username.trim().to_lowercase();
|
||||||
|
if username.len() < 3 {
|
||||||
|
return Redirect::to("/admin/users?error=Username must be at least 3 characters")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
if !username
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
return Redirect::to(
|
||||||
|
"/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.password.trim().len() < 8 {
|
||||||
|
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let account_type = if form.account_type.trim().is_empty() {
|
||||||
|
"standard"
|
||||||
|
} else {
|
||||||
|
form.account_type.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = if form.metadata.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(form.metadata.trim())
|
||||||
|
};
|
||||||
|
|
||||||
|
let hash = match hash_password(&form.password) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_user = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||||
|
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||||
|
{
|
||||||
|
return Redirect::to("/admin/users?error=Username already exists").into_response();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Redirect::to(&format!("/admin/users?error=Database error: {}", e))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||||
|
return Redirect::to(&format!(
|
||||||
|
"/admin/users?error=Failed to initialize user databases: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_CREATION",
|
||||||
|
Some("user"),
|
||||||
|
Some(&new_user.id.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Redirect::to("/admin/users?success=User created successfully").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn users_update_status_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Form(form): Form<UpdateUserStatusForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = form.status.trim().to_lowercase();
|
||||||
|
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
|
||||||
|
return Redirect::to("/admin/users?error=Invalid user status").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
match crate::db::users::update_user_status(&conn, id, &status) {
|
||||||
|
Ok(_) => {
|
||||||
|
let conn_admin = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn_admin,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_STATUS_UPDATE",
|
||||||
|
Some("user"),
|
||||||
|
Some(&id.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/users?success=User status updated").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!(
|
||||||
|
"/admin/users?error=Failed to update status: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn users_update_type_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Form(form): Form<UpdateUserTypeForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let account_type = form.account_type.trim().to_lowercase();
|
||||||
|
if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str())
|
||||||
|
{
|
||||||
|
return Redirect::to("/admin/users?error=Invalid account type").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
match crate::db::users::update_user_account_type(&conn, id, &account_type) {
|
||||||
|
Ok(_) => {
|
||||||
|
let conn_admin = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn_admin,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_ACCOUNT_TYPE_UPDATE",
|
||||||
|
Some("user"),
|
||||||
|
Some(&id.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/users?success=User account type updated").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!(
|
||||||
|
"/admin/users?error=Failed to update account type: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn users_reset_password_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Form(form): Form<ResetPasswordForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.new_password.trim().len() < 8 {
|
||||||
|
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = match hash_password(&form.new_password) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
match crate::db::users::reset_user_password(&conn, id, &hash) {
|
||||||
|
Ok(_) => {
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
let conn_admin = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn_admin,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_PASSWORD_RESET",
|
||||||
|
Some("user"),
|
||||||
|
Some(&id.to_string()),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/users?success=Password reset successfully").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!(
|
||||||
|
"/admin/users?error=Failed to reset password: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn users_delete_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Form(form): Form<DeleteUserForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) {
|
||||||
|
Ok(_) => {
|
||||||
|
let ip = get_client_ip(&headers, connect_info);
|
||||||
|
let conn_admin = state.admin_db.lock().unwrap();
|
||||||
|
let _ = write_audit_log(
|
||||||
|
&conn_admin,
|
||||||
|
&state,
|
||||||
|
&user.username,
|
||||||
|
"USER_DELETION",
|
||||||
|
Some("user"),
|
||||||
|
Some(&id.to_string()),
|
||||||
|
Some(&ip),
|
||||||
|
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/users?success=User deleted successfully").into_response()
|
||||||
|
}
|
||||||
|
Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GA Hardening UI Handlers and Structs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct UserDetailStats {
|
||||||
|
pub max_urls: i64,
|
||||||
|
pub max_landings: i64,
|
||||||
|
pub max_api_tokens: i64,
|
||||||
|
pub max_storage_mb: i64,
|
||||||
|
pub current_urls: i64,
|
||||||
|
pub current_landings: i64,
|
||||||
|
pub current_api_tokens: i64,
|
||||||
|
pub current_storage_mb: i64,
|
||||||
|
pub url_pct: i64,
|
||||||
|
pub landing_pct: i64,
|
||||||
|
pub token_pct: i64,
|
||||||
|
pub storage_pct: i64,
|
||||||
|
pub total_visits: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_user_detail_stats(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
|
) -> Result<UserDetailStats, Box<dyn std::error::Error>> {
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
|
let quotas = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
|
||||||
|
FROM quotas WHERE user_id = ?1;",
|
||||||
|
[user_id],
|
||||||
|
|row| Ok(crate::models::UserQuotas {
|
||||||
|
user_id,
|
||||||
|
max_urls: row.get(0)?,
|
||||||
|
max_landings: row.get(1)?,
|
||||||
|
max_api_tokens: row.get(2)?,
|
||||||
|
max_storage_mb: row.get(3)?,
|
||||||
|
current_urls: row.get(4)?,
|
||||||
|
current_landings: row.get(5)?,
|
||||||
|
current_api_tokens: row.get(6)?,
|
||||||
|
current_storage_mb: row.get(7)?,
|
||||||
|
})
|
||||||
|
).optional()?
|
||||||
|
};
|
||||||
|
|
||||||
|
let quotas = quotas.unwrap_or(crate::models::UserQuotas {
|
||||||
|
user_id,
|
||||||
|
max_urls: 100,
|
||||||
|
max_landings: 10,
|
||||||
|
max_api_tokens: 5,
|
||||||
|
max_storage_mb: 100,
|
||||||
|
current_urls: 0,
|
||||||
|
current_landings: 0,
|
||||||
|
current_api_tokens: 0,
|
||||||
|
current_storage_mb: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let total_visits = {
|
||||||
|
if let Ok(dbs) = state.get_user_dbs(user_id) {
|
||||||
|
let conn = dbs.analytics.lock().unwrap();
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| {
|
||||||
|
row.get::<_, i64>(0)
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let url_pct = if quotas.max_urls > 0 {
|
||||||
|
(quotas.current_urls * 100) / quotas.max_urls
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let landing_pct = if quotas.max_landings > 0 {
|
||||||
|
(quotas.current_landings * 100) / quotas.max_landings
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let token_pct = if quotas.max_api_tokens > 0 {
|
||||||
|
(quotas.current_api_tokens * 100) / quotas.max_api_tokens
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let storage_pct = if quotas.max_storage_mb > 0 {
|
||||||
|
(quotas.current_storage_mb * 100) / quotas.max_storage_mb
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(UserDetailStats {
|
||||||
|
max_urls: quotas.max_urls,
|
||||||
|
max_landings: quotas.max_landings,
|
||||||
|
max_api_tokens: quotas.max_api_tokens,
|
||||||
|
max_storage_mb: quotas.max_storage_mb,
|
||||||
|
current_urls: quotas.current_urls,
|
||||||
|
current_landings: quotas.current_landings,
|
||||||
|
current_api_tokens: quotas.current_api_tokens,
|
||||||
|
current_storage_mb: quotas.current_storage_mb,
|
||||||
|
url_pct,
|
||||||
|
landing_pct,
|
||||||
|
token_pct,
|
||||||
|
storage_pct,
|
||||||
|
total_visits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/users/new
|
||||||
|
pub async fn users_new_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::UsersNewTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/users/:id
|
||||||
|
pub async fn user_detail_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_user = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let u_res = conn.query_row(
|
||||||
|
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||||
|
FROM users WHERE id = ?1;",
|
||||||
|
[id],
|
||||||
|
|row| Ok(crate::models::TenantUser {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
password_hash: row.get(2)?,
|
||||||
|
status: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
last_login: row.get(5)?,
|
||||||
|
account_type: row.get(6)?,
|
||||||
|
organization_id: row.get(7)?,
|
||||||
|
metadata: row.get(8)?,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
match u_res {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stats = match get_user_detail_stats(&state, id) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sessions = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;")
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([id], |row| {
|
||||||
|
Ok(crate::models::UserSession {
|
||||||
|
id: row.get(0)?,
|
||||||
|
user_id: row.get(1)?,
|
||||||
|
expires_at: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let tokens = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([id], |row| {
|
||||||
|
Ok(crate::models::UserApiToken {
|
||||||
|
id: row.get(0)?,
|
||||||
|
user_id: row.get(1)?,
|
||||||
|
token_hash: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::UserDetailTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
target_user,
|
||||||
|
stats,
|
||||||
|
sessions,
|
||||||
|
tokens,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/users/:id/edit
|
||||||
|
pub async fn user_edit_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_user = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let u_res = conn.query_row(
|
||||||
|
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||||
|
FROM users WHERE id = ?1;",
|
||||||
|
[id],
|
||||||
|
|row| Ok(crate::models::TenantUser {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
password_hash: row.get(2)?,
|
||||||
|
status: row.get(3)?,
|
||||||
|
created_at: row.get(4)?,
|
||||||
|
last_login: row.get(5)?,
|
||||||
|
account_type: row.get(6)?,
|
||||||
|
organization_id: row.get(7)?,
|
||||||
|
metadata: row.get(8)?,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
match u_res {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let quotas = {
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
|
||||||
|
FROM quotas WHERE user_id = ?1;",
|
||||||
|
[id],
|
||||||
|
|row| Ok(crate::models::UserQuotas {
|
||||||
|
user_id: id,
|
||||||
|
max_urls: row.get(0)?,
|
||||||
|
max_landings: row.get(1)?,
|
||||||
|
max_api_tokens: row.get(2)?,
|
||||||
|
max_storage_mb: row.get(3)?,
|
||||||
|
current_urls: row.get(4)?,
|
||||||
|
current_landings: row.get(5)?,
|
||||||
|
current_api_tokens: row.get(6)?,
|
||||||
|
current_storage_mb: row.get(7)?,
|
||||||
|
})
|
||||||
|
).unwrap_or(crate::models::UserQuotas {
|
||||||
|
user_id: id,
|
||||||
|
max_urls: 100,
|
||||||
|
max_landings: 10,
|
||||||
|
max_api_tokens: 5,
|
||||||
|
max_storage_mb: 100,
|
||||||
|
current_urls: 0,
|
||||||
|
current_landings: 0,
|
||||||
|
current_api_tokens: 0,
|
||||||
|
current_storage_mb: 0,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::UserEditTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
target_user,
|
||||||
|
quotas,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserEditForm {
|
||||||
|
pub account_type: String,
|
||||||
|
pub metadata: String,
|
||||||
|
pub max_urls: i64,
|
||||||
|
pub max_landings: i64,
|
||||||
|
pub max_api_tokens: i64,
|
||||||
|
pub max_storage_mb: i64,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/users/:id/edit
|
||||||
|
pub async fn user_edit_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Form(form): Form<UserEditForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (_user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||||
|
return Redirect::to(&format!(
|
||||||
|
"/admin/users/{}/edit?error=Invalid CSRF token",
|
||||||
|
id
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = conn.execute(
|
||||||
|
"UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;",
|
||||||
|
rusqlite::params![form.account_type, form.metadata, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = conn.execute(
|
||||||
|
"INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5) \
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET \
|
||||||
|
max_urls = excluded.max_urls, \
|
||||||
|
max_landings = excluded.max_landings, \
|
||||||
|
max_api_tokens = excluded.max_api_tokens, \
|
||||||
|
max_storage_mb = excluded.max_storage_mb;",
|
||||||
|
rusqlite::params![
|
||||||
|
id,
|
||||||
|
form.max_urls,
|
||||||
|
form.max_landings,
|
||||||
|
form.max_api_tokens,
|
||||||
|
form.max_storage_mb
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/admin/users/{}?success=User updated successfully",
|
||||||
|
id
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
+25
-27
@@ -99,34 +99,23 @@ pub async fn api_create_url(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dest = payload.destination.trim().to_string();
|
let dest = match crate::services::urls::prepare_destination(
|
||||||
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
|
&payload.destination,
|
||||||
let mut has_utm = false;
|
crate::services::urls::UtmParams {
|
||||||
{
|
source: payload.utm_source.as_deref(),
|
||||||
let mut query = parsed.query_pairs_mut();
|
medium: payload.utm_medium.as_deref(),
|
||||||
if let Some(ref src) = payload.utm_source {
|
campaign: payload.utm_campaign.as_deref(),
|
||||||
if !src.trim().is_empty() {
|
},
|
||||||
query.append_pair("utm_source", src.trim());
|
) {
|
||||||
has_utm = true;
|
Ok(d) => d,
|
||||||
}
|
Err(msg) => {
|
||||||
}
|
return (
|
||||||
if let Some(ref med) = payload.utm_medium {
|
StatusCode::BAD_REQUEST,
|
||||||
if !med.trim().is_empty() {
|
Json(serde_json::json!({ "error": msg })),
|
||||||
query.append_pair("utm_medium", med.trim());
|
)
|
||||||
has_utm = true;
|
.into_response();
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref camp) = payload.utm_campaign {
|
|
||||||
if !camp.trim().is_empty() {
|
|
||||||
query.append_pair("utm_campaign", camp.trim());
|
|
||||||
has_utm = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if has_utm {
|
};
|
||||||
dest = parsed.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let password_hash = if let Some(ref pwd) = payload.password {
|
let password_hash = if let Some(ref pwd) = payload.password {
|
||||||
if pwd.is_empty() {
|
if pwd.is_empty() {
|
||||||
@@ -353,6 +342,15 @@ pub async fn api_update_url(
|
|||||||
Json(payload): Json<UpdateUrlRequest>,
|
Json(payload): Json<UpdateUrlRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let tags = payload.tags.unwrap_or_default();
|
let tags = payload.tags.unwrap_or_default();
|
||||||
|
if !crate::utils::validation::validate_redirect_destination(&payload.destination) {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Destination must be a valid http(s) URL without control characters"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
let conn = state.content_db.lock().unwrap();
|
let conn = state.content_db.lock().unwrap();
|
||||||
match update_url(
|
match update_url(
|
||||||
&conn,
|
&conn,
|
||||||
|
|||||||
+54
-208
@@ -1,8 +1,9 @@
|
|||||||
use crate::auth::generate_token;
|
|
||||||
use crate::auth::password::hash_password;
|
|
||||||
use crate::auth::ApiUser;
|
use crate::auth::ApiUser;
|
||||||
|
use crate::services::bulk_urls::{
|
||||||
|
create_urls_bulk, ensure_url_quota, BulkUrlCreateItem, BulkUrlError,
|
||||||
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::get_client_ip;
|
use crate::utils::{get_client_ip, lock_db};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{ConnectInfo, State},
|
extract::{ConnectInfo, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
@@ -68,7 +69,18 @@ pub async fn api_bulk_qr(
|
|||||||
// Retrieve URLs from database
|
// Retrieve URLs from database
|
||||||
let mut urls = Vec::new();
|
let mut urls = Vec::new();
|
||||||
{
|
{
|
||||||
let conn = state.content_db.lock().unwrap();
|
let conn = match lock_db(&state.content_db, "content_db") {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(BulkErrorResponse {
|
||||||
|
error: e.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
for id in &payload.ids {
|
for id in &payload.ids {
|
||||||
match crate::db::content::get_url_by_id(&conn, id) {
|
match crate::db::content::get_url_by_id(&conn, id) {
|
||||||
Ok(Some(url)) => urls.push(url),
|
Ok(Some(url)) => urls.push(url),
|
||||||
@@ -115,9 +127,8 @@ pub async fn api_bulk_qr(
|
|||||||
// Generate ZIP
|
// Generate ZIP
|
||||||
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
|
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
|
||||||
Ok(zip_data) => {
|
Ok(zip_data) => {
|
||||||
// Write Audit Log
|
// Write Audit Log (best-effort; do not fail the download on audit lock poison)
|
||||||
{
|
if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
|
||||||
let system_conn = state.db.system.lock().unwrap();
|
|
||||||
let _ = crate::db::audit_events::write_audit_event(
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
&system_conn,
|
&system_conn,
|
||||||
user.0.username(),
|
user.0.username(),
|
||||||
@@ -147,6 +158,16 @@ pub async fn api_bulk_qr(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bulk_url_error_response(err: BulkUrlError) -> Response {
|
||||||
|
let (status, msg) = match &err {
|
||||||
|
BulkUrlError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||||
|
BulkUrlError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
|
||||||
|
BulkUrlError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
|
||||||
|
BulkUrlError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
|
||||||
|
};
|
||||||
|
(status, Json(BulkErrorResponse { error: msg })).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/v1/bulk/url
|
// POST /api/v1/bulk/url
|
||||||
pub async fn api_bulk_url(
|
pub async fn api_bulk_url(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -185,214 +206,39 @@ pub async fn api_bulk_url(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check quota
|
if let Err(e) = ensure_url_quota(&state.users_db, target_user_id, payload.len() as i64) {
|
||||||
{
|
return bulk_url_error_response(e);
|
||||||
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 items: Vec<BulkUrlCreateItem> = payload
|
||||||
let tx = match conn.transaction() {
|
.into_iter()
|
||||||
Ok(t) => t,
|
.map(|item| BulkUrlCreateItem {
|
||||||
Err(e) => {
|
destination: item.destination,
|
||||||
return (
|
code: item.code,
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
title: item.title,
|
||||||
Json(BulkErrorResponse {
|
description: item.description,
|
||||||
error: format!("Failed to start database transaction: {}", e),
|
tags: item.tags,
|
||||||
}),
|
expires_at: item.expires_at,
|
||||||
)
|
password: item.password,
|
||||||
.into_response()
|
max_access_count: item.max_access_count,
|
||||||
}
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let created_urls = match create_urls_bulk(
|
||||||
|
&content_db,
|
||||||
|
&state.system_db,
|
||||||
|
&state.users_db,
|
||||||
|
target_user_id,
|
||||||
|
items,
|
||||||
|
) {
|
||||||
|
Ok(urls) => urls,
|
||||||
|
Err(e) => return bulk_url_error_response(e),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut created_urls = Vec::new();
|
|
||||||
let mut reserved_slugs: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
for item in payload {
|
|
||||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
|
||||||
if code.is_empty() {
|
|
||||||
code = generate_token(3); // 6 hex
|
|
||||||
} else {
|
|
||||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
||||||
let _ = tx.rollback();
|
|
||||||
// Release reserving slugs
|
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
|
||||||
for slug in &reserved_slugs {
|
|
||||||
let _ =
|
|
||||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(BulkErrorResponse {
|
|
||||||
error: format!("Short code '{}' must be 6 hex characters", code),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reserve slug
|
|
||||||
{
|
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
|
||||||
// Check availability in system.db and also check in our currently reserved slugs in this batch
|
|
||||||
let available = crate::db::users::is_slug_available(&system_conn, &code)
|
|
||||||
.unwrap_or(false)
|
|
||||||
&& !reserved_slugs.contains(&code);
|
|
||||||
|
|
||||||
if !available {
|
|
||||||
let _ = tx.rollback();
|
|
||||||
for slug in &reserved_slugs {
|
|
||||||
let _ =
|
|
||||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
Json(BulkErrorResponse {
|
|
||||||
error: format!("Short code '{}' already exists", code),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = crate::db::users::register_global_slug(
|
|
||||||
&system_conn,
|
|
||||||
&code,
|
|
||||||
target_user_id,
|
|
||||||
"url",
|
|
||||||
"",
|
|
||||||
"reserving",
|
|
||||||
) {
|
|
||||||
let _ = tx.rollback();
|
|
||||||
for slug in &reserved_slugs {
|
|
||||||
let _ =
|
|
||||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(BulkErrorResponse {
|
|
||||||
error: format!("Failed to reserve slug '{}': {}", code, e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
reserved_slugs.push(code.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let password_hash = if let Some(ref pwd) = item.password {
|
|
||||||
match hash_password(pwd) {
|
|
||||||
Ok(h) => Some(h),
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.rollback();
|
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
|
||||||
for slug in &reserved_slugs {
|
|
||||||
let _ = crate::db::users::release_global_slug(
|
|
||||||
&system_conn,
|
|
||||||
slug,
|
|
||||||
target_user_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(BulkErrorResponse {
|
|
||||||
error: format!("Password hashing error: {}", e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let tags = item.tags.unwrap_or_default();
|
|
||||||
match crate::db::content::create_url_extended(
|
|
||||||
&tx,
|
|
||||||
&code,
|
|
||||||
&item.destination,
|
|
||||||
item.title.as_deref(),
|
|
||||||
item.description.as_deref(),
|
|
||||||
&tags,
|
|
||||||
item.expires_at.as_deref(),
|
|
||||||
password_hash.as_deref(),
|
|
||||||
item.max_access_count,
|
|
||||||
) {
|
|
||||||
Ok(url) => created_urls.push(url),
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.rollback();
|
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
|
||||||
for slug in &reserved_slugs {
|
|
||||||
let _ =
|
|
||||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(BulkErrorResponse {
|
|
||||||
error: format!("Database insert error: {}", e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = tx.commit() {
|
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
|
||||||
for slug in &reserved_slugs {
|
|
||||||
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(BulkErrorResponse {
|
|
||||||
error: format!("Failed to commit transaction: {}", e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate slugs
|
|
||||||
{
|
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
|
||||||
for url in &created_urls {
|
|
||||||
let _ = system_conn.execute(
|
|
||||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
|
||||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment quota counters
|
|
||||||
{
|
|
||||||
let users_conn = state.users_db.lock().unwrap();
|
|
||||||
for _ in 0..created_urls.len() {
|
|
||||||
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write Audit Log for the entire batch
|
// 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());
|
||||||
{
|
if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
|
||||||
let system_conn = state.db.system.lock().unwrap();
|
|
||||||
let _ = crate::db::audit_events::write_audit_event(
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
&system_conn,
|
&system_conn,
|
||||||
user.0.username(),
|
user.0.username(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub async fn gate_post(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Form(form): Form<PasswordGateForm>,
|
Form(form): Form<PasswordGateForm>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||||
@@ -55,8 +56,9 @@ pub async fn gate_post(
|
|||||||
if verify_password(&form.password, password_hash) {
|
if verify_password(&form.password, password_hash) {
|
||||||
// Correct password - set 15 min temporary cookie
|
// Correct password - set 15 min temporary cookie
|
||||||
let cookie_name = format!("bzod_gate_{}", code);
|
let cookie_name = format!("bzod_gate_{}", code);
|
||||||
|
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||||
let cookie = Cookie::build((cookie_name, "authorized"))
|
let cookie = Cookie::build((cookie_name, "authorized"))
|
||||||
.secure(state.config.cookie_secure)
|
.secure(secure_flag)
|
||||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.path("/")
|
.path("/")
|
||||||
|
|||||||
+380
-85
@@ -1,20 +1,298 @@
|
|||||||
|
//! Public short-code redirect hot path.
|
||||||
|
//!
|
||||||
|
//! Design notes:
|
||||||
|
//! - Destination `Location` headers never panic on malformed values.
|
||||||
|
//! - Content DB work uses a single mutex acquisition where safe.
|
||||||
|
//! - Expiration is enforced on the read path; persistent `expired=1` is left to
|
||||||
|
//! the background expiry job (`jobs::expiry`), not written here.
|
||||||
|
//! - Blocking rusqlite work runs in `spawn_blocking` so Tokio workers are not starved.
|
||||||
|
//! - Analytics enqueue remains non-blocking (`try_send` via the queue).
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{ConnectInfo, Path, State},
|
extract::{ConnectInfo, Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use axum_extra::extract::CookieJar;
|
use axum_extra::extract::CookieJar;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rusqlite::OptionalExtension;
|
use rusqlite::OptionalExtension;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tracing::{error, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::analytics::get_client_country;
|
use crate::analytics::get_client_country;
|
||||||
use crate::models::VisitRecord;
|
use crate::models::{LinkPreview, Url, VisitRecord};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::templates::PreviewTemplate;
|
use crate::templates::PreviewTemplate;
|
||||||
use crate::utils::get_client_ip;
|
use crate::utils::get_client_ip;
|
||||||
|
|
||||||
|
/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
|
||||||
|
enum ResolveOutcome {
|
||||||
|
Ready(Box<ResolvedUrl>),
|
||||||
|
/// Early HTTP response that does not need further processing.
|
||||||
|
Early {
|
||||||
|
status: StatusCode,
|
||||||
|
body: &'static str,
|
||||||
|
},
|
||||||
|
/// Permanent redirect to a relative path (e.g. page target → `/p/{code}`).
|
||||||
|
PermanentPath(String),
|
||||||
|
DbError {
|
||||||
|
operation: &'static str,
|
||||||
|
message: String,
|
||||||
|
owner_user_id: Option<i64>,
|
||||||
|
resource_id: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe client-facing DB error after structured server-side logging.
|
||||||
|
fn db_error_response(
|
||||||
|
operation: &str,
|
||||||
|
code: &str,
|
||||||
|
owner_user_id: Option<i64>,
|
||||||
|
resource_id: Option<&str>,
|
||||||
|
err: impl std::fmt::Display,
|
||||||
|
) -> Response {
|
||||||
|
error!(
|
||||||
|
operation = operation,
|
||||||
|
code = code,
|
||||||
|
owner_user_id = owner_user_id,
|
||||||
|
resource_id = resource_id.unwrap_or(""),
|
||||||
|
error = %err,
|
||||||
|
"redirect path database error"
|
||||||
|
);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a permanent redirect without panicking on invalid destinations.
|
||||||
|
///
|
||||||
|
/// Defense in depth:
|
||||||
|
/// 1. Canonical destination rules (http/https, no control chars) — same as writes.
|
||||||
|
/// 2. `HeaderValue` construction — rejects remaining illegal header bytes.
|
||||||
|
///
|
||||||
|
/// Neither step may panic. Full destination values are not logged.
|
||||||
|
fn permanent_redirect_to(destination: &str, code: &str) -> Response {
|
||||||
|
if !crate::utils::validation::validate_redirect_destination(destination) {
|
||||||
|
warn!(
|
||||||
|
operation = "validate_redirect_destination",
|
||||||
|
code = code,
|
||||||
|
destination_len = destination.len(),
|
||||||
|
"invalid stored redirect destination rejected"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Invalid redirect destination",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match HeaderValue::from_str(destination) {
|
||||||
|
Ok(loc) => {
|
||||||
|
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
|
||||||
|
resp.headers_mut().insert(header::LOCATION, loc);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
operation = "build_location_header",
|
||||||
|
code = code,
|
||||||
|
error = %err,
|
||||||
|
// Do not log the full destination if it may contain control chars;
|
||||||
|
// log length only for forensics.
|
||||||
|
destination_len = destination.len(),
|
||||||
|
"invalid redirect destination rejected (possible response-splitting attempt)"
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Invalid redirect destination",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the initial global-slug + URL resolution phase.
|
||||||
|
struct ResolvedUrl {
|
||||||
|
owner_user_id: i64,
|
||||||
|
url: Url,
|
||||||
|
content: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lookup global slug namespace then load the URL from the tenant content DB.
|
||||||
|
/// Runs entirely on a blocking thread.
|
||||||
|
fn resolve_url_blocking(
|
||||||
|
system_db: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
state: AppState,
|
||||||
|
code: &str,
|
||||||
|
) -> ResolveOutcome {
|
||||||
|
// 1. Query global slug namespace in system.db
|
||||||
|
let slug_info = {
|
||||||
|
let system_conn = match system_db.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return ResolveOutcome::DbError {
|
||||||
|
operation: "lock_system_db",
|
||||||
|
message: e.to_string(),
|
||||||
|
owner_user_id: None,
|
||||||
|
resource_id: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut stmt = match system_conn.prepare(
|
||||||
|
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
return ResolveOutcome::DbError {
|
||||||
|
operation: "prepare_global_slugs",
|
||||||
|
message: e.to_string(),
|
||||||
|
owner_user_id: None,
|
||||||
|
resource_id: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match stmt
|
||||||
|
.query_row(rusqlite::params![code], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
{
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(e) => {
|
||||||
|
return ResolveOutcome::DbError {
|
||||||
|
operation: "query_global_slugs",
|
||||||
|
message: e.to_string(),
|
||||||
|
owner_user_id: None,
|
||||||
|
resource_id: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||||
|
Some(info) => info,
|
||||||
|
None => {
|
||||||
|
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||||
|
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||||
|
if slug_status != "active" {
|
||||||
|
return ResolveOutcome::Early {
|
||||||
|
status: StatusCode::GONE,
|
||||||
|
body: "This content has been disabled or moderated",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If target type is page, redirect permanently to /p/slug
|
||||||
|
if target_type == "page" {
|
||||||
|
return ResolveOutcome::PermanentPath(format!("/p/{}", code));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get content database connection via tenant DB resolution
|
||||||
|
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||||
|
Ok(dbs) => dbs,
|
||||||
|
Err(e) => {
|
||||||
|
return ResolveOutcome::DbError {
|
||||||
|
operation: "get_user_dbs",
|
||||||
|
message: e.to_string(),
|
||||||
|
owner_user_id: Some(owner_user_id),
|
||||||
|
resource_id: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = {
|
||||||
|
let conn = match content_conn.content.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return ResolveOutcome::DbError {
|
||||||
|
operation: "lock_content_db",
|
||||||
|
message: e.to_string(),
|
||||||
|
owner_user_id: Some(owner_user_id),
|
||||||
|
resource_id: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match crate::db::content::get_url_by_code(&conn, code) {
|
||||||
|
Ok(Some(url)) => url,
|
||||||
|
Ok(None) => {
|
||||||
|
return ResolveOutcome::Early {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
body: "Short code not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return ResolveOutcome::DbError {
|
||||||
|
operation: "get_url_by_code",
|
||||||
|
message: e.to_string(),
|
||||||
|
owner_user_id: Some(owner_user_id),
|
||||||
|
resource_id: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ResolveOutcome::Ready(Box::new(ResolvedUrl {
|
||||||
|
owner_user_id,
|
||||||
|
url,
|
||||||
|
content: content_conn.content,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment access count and load preview under a single content-DB lock.
|
||||||
|
fn increment_and_preview_blocking(
|
||||||
|
content: Arc<Mutex<rusqlite::Connection>>,
|
||||||
|
url_id: &str,
|
||||||
|
code: &str,
|
||||||
|
owner_user_id: i64,
|
||||||
|
fallback_access_count: i64,
|
||||||
|
) -> Result<(i64, Option<LinkPreview>), String> {
|
||||||
|
let conn = content
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("lock_content_db_hot: {}", e))?;
|
||||||
|
|
||||||
|
let new_access_count = match crate::db::content::increment_access_count(&conn, url_id) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
// Preserve prior soft-failure semantics for the counter value used only
|
||||||
|
// internally; never silence the underlying error.
|
||||||
|
error!(
|
||||||
|
operation = "increment_access_count",
|
||||||
|
code = code,
|
||||||
|
owner_user_id = owner_user_id,
|
||||||
|
resource_id = url_id,
|
||||||
|
error = %e,
|
||||||
|
"failed to increment access count; continuing with estimated value"
|
||||||
|
);
|
||||||
|
fallback_access_count + 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let preview = match crate::db::preview::get_preview(&conn, url_id) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
operation = "get_preview",
|
||||||
|
code = code,
|
||||||
|
owner_user_id = owner_user_id,
|
||||||
|
resource_id = url_id,
|
||||||
|
error = %e,
|
||||||
|
"failed to load link preview; continuing without preview"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((new_access_count, preview))
|
||||||
|
}
|
||||||
|
|
||||||
// GET /:code
|
// GET /:code
|
||||||
// Resolve and redirect
|
// Resolve and redirect
|
||||||
pub async fn resolve_redirect(
|
pub async fn resolve_redirect(
|
||||||
@@ -31,69 +309,51 @@ pub async fn resolve_redirect(
|
|||||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Query global slug namespace in system.db
|
let system_db = state.system_db.clone();
|
||||||
let slug_info = {
|
let state_for_lookup = state.clone();
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
let code_for_lookup = code.clone();
|
||||||
let mut stmt = match system_conn.prepare(
|
|
||||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
|
||||||
) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
||||||
};
|
|
||||||
stmt.query_row(rusqlite::params![code], |row| {
|
|
||||||
Ok((
|
|
||||||
row.get::<_, i64>(0)?,
|
|
||||||
row.get::<_, String>(1)?,
|
|
||||||
row.get::<_, String>(2)?,
|
|
||||||
row.get::<_, String>(3)?,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
};
|
|
||||||
|
|
||||||
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
let outcome = match tokio::task::spawn_blocking(move || {
|
||||||
Ok(Some(info)) => info,
|
resolve_url_blocking(system_db, state_for_lookup, &code_for_lookup)
|
||||||
Ok(None) => {
|
})
|
||||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
.await
|
||||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
{
|
||||||
}
|
Ok(outcome) => outcome,
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
Err(e) => {
|
||||||
};
|
return db_error_response("spawn_blocking_resolve", &code, None, None, e.to_string());
|
||||||
|
|
||||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
|
||||||
if slug_status != "active" {
|
|
||||||
return (
|
|
||||||
StatusCode::GONE,
|
|
||||||
"This content has been disabled or moderated",
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If target type is page, redirect permanently to /p/slug
|
|
||||||
if target_type == "page" {
|
|
||||||
return Redirect::permanent(&format!("/p/{}", code)).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get content database connection via tenant DB resolution
|
|
||||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
|
||||||
Ok(dbs) => dbs.content,
|
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url_opt = {
|
|
||||||
let conn = content_conn.lock().unwrap();
|
|
||||||
match crate::db::content::get_url_by_code(&conn, &code) {
|
|
||||||
Ok(url) => url,
|
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = match url_opt {
|
let resolved = match outcome {
|
||||||
Some(u) => u,
|
ResolveOutcome::Ready(r) => r,
|
||||||
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
ResolveOutcome::Early { status, body } => return (status, body).into_response(),
|
||||||
|
ResolveOutcome::PermanentPath(path) => {
|
||||||
|
return Redirect::permanent(&path).into_response();
|
||||||
|
}
|
||||||
|
ResolveOutcome::DbError {
|
||||||
|
operation,
|
||||||
|
message,
|
||||||
|
owner_user_id,
|
||||||
|
resource_id,
|
||||||
|
} => {
|
||||||
|
return db_error_response(
|
||||||
|
operation,
|
||||||
|
&code,
|
||||||
|
owner_user_id,
|
||||||
|
resource_id.as_deref(),
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Expiration check
|
let ResolvedUrl {
|
||||||
|
owner_user_id,
|
||||||
|
url,
|
||||||
|
content,
|
||||||
|
} = *resolved;
|
||||||
|
|
||||||
|
// 3. Expiration check (read-only on the hot path).
|
||||||
|
// Background job `jobs::expiry::run_expiry_checker` persists expired=1.
|
||||||
if url.expired {
|
if url.expired {
|
||||||
return (StatusCode::GONE, "This link has expired").into_response();
|
return (StatusCode::GONE, "This link has expired").into_response();
|
||||||
}
|
}
|
||||||
@@ -101,14 +361,6 @@ pub async fn resolve_redirect(
|
|||||||
if let Some(ref expires_at_str) = url.expires_at {
|
if let Some(ref expires_at_str) = url.expires_at {
|
||||||
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
|
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
|
||||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||||
// Mark as expired in DB asynchronously/immediately
|
|
||||||
{
|
|
||||||
let conn = content_conn.lock().unwrap();
|
|
||||||
let _ = conn.execute(
|
|
||||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
|
||||||
[url.id.clone()],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (StatusCode::GONE, "This link has expired").into_response();
|
return (StatusCode::GONE, "This link has expired").into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,15 +388,40 @@ pub async fn resolve_redirect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Increment access count & retrieve preview config
|
// 6. Increment access count & retrieve preview config (single content lock, off executor)
|
||||||
let _new_access_count = {
|
let url_id = url.id.clone();
|
||||||
let conn = content_conn.lock().unwrap();
|
let code_for_hot = code.clone();
|
||||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
let fallback_access_count = url.access_count;
|
||||||
};
|
let preview_opt = match tokio::task::spawn_blocking(move || {
|
||||||
|
increment_and_preview_blocking(
|
||||||
let preview_opt = {
|
content,
|
||||||
let conn = content_conn.lock().unwrap();
|
&url_id,
|
||||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
&code_for_hot,
|
||||||
|
owner_user_id,
|
||||||
|
fallback_access_count,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok((_new_access_count, preview))) => preview,
|
||||||
|
Ok(Err(msg)) => {
|
||||||
|
return db_error_response(
|
||||||
|
"increment_and_preview",
|
||||||
|
&code,
|
||||||
|
Some(owner_user_id),
|
||||||
|
Some(&url.id),
|
||||||
|
msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return db_error_response(
|
||||||
|
"spawn_blocking_hot",
|
||||||
|
&code,
|
||||||
|
Some(owner_user_id),
|
||||||
|
Some(&url.id),
|
||||||
|
e.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Asynchronously record analytics
|
// Asynchronously record analytics
|
||||||
@@ -195,14 +472,32 @@ pub async fn resolve_redirect(
|
|||||||
}
|
}
|
||||||
.into_response()
|
.into_response()
|
||||||
} else {
|
} else {
|
||||||
{
|
permanent_redirect_to(&url.destination, &code)
|
||||||
use axum::http::{header, HeaderValue};
|
}
|
||||||
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
|
}
|
||||||
resp.headers_mut().insert(
|
|
||||||
header::LOCATION,
|
#[cfg(test)]
|
||||||
HeaderValue::from_str(&url.destination).unwrap(),
|
mod tests {
|
||||||
);
|
use super::*;
|
||||||
resp
|
|
||||||
}
|
#[test]
|
||||||
|
fn permanent_redirect_rejects_crlf() {
|
||||||
|
let resp = permanent_redirect_to("https://evil.example/\r\nX-Injected: yes", "abc123");
|
||||||
|
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permanent_redirect_rejects_control_chars() {
|
||||||
|
let resp = permanent_redirect_to("https://evil.example/\x00payload", "abc123");
|
||||||
|
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permanent_redirect_accepts_valid_url() {
|
||||||
|
let resp = permanent_redirect_to("https://example.com/path?q=1", "abc123");
|
||||||
|
assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY);
|
||||||
|
let loc = resp.headers().get(header::LOCATION).unwrap();
|
||||||
|
assert_eq!(loc, "https://example.com/path?q=1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,9 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
|
|||||||
|
|
||||||
fn build_state(config: Config) -> (Db, AppState) {
|
fn build_state(config: Config) -> (Db, AppState) {
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
content_db: db.content.clone(),
|
content_db: db.content.clone(),
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ async fn start_test_server(
|
|||||||
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
||||||
let config = create_temp_config(temp_dir);
|
let config = create_temp_config(temp_dir);
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 100);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ fn compute_sha256(value: &str) -> String {
|
|||||||
|
|
||||||
fn build_state(config: Config) -> (Db, bzod::state::AppState) {
|
fn build_state(config: Config) -> (Db, bzod::state::AppState) {
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||||
let state = bzod::state::AppState {
|
let state = bzod::state::AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
content_db: db.content.clone(),
|
content_db: db.content.clone(),
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ async fn start_test_server(
|
|||||||
) {
|
) {
|
||||||
let config = create_temp_config(temp_dir);
|
let config = create_temp_config(temp_dir);
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 10);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
|
|||||||
config.backup_dir = temp_dir.clone();
|
config.backup_dir = temp_dir.clone();
|
||||||
config.admin_username = "admin".to_string();
|
config.admin_username = "admin".to_string();
|
||||||
config.base_url = Some("http://localhost:8080".to_string());
|
config.base_url = Some("http://localhost:8080".to_string());
|
||||||
config.cookie_secure = false; // Disable secure flag for testing over HTTP loopback
|
// We leave config.cookie_secure as default (true).
|
||||||
|
// The new resolve_cookie_secure logic will automatically drop Secure
|
||||||
|
// for HTTP requests over the 127.0.0.1 loopback during this test,
|
||||||
|
// proving the local development fix works end-to-end.
|
||||||
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
|
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
@@ -45,7 +48,9 @@ async fn start_test_server(
|
|||||||
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) {
|
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) {
|
||||||
let config = create_temp_config(temp_dir);
|
let config = create_temp_config(temp_dir);
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 100);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
|
|||||||
@@ -0,0 +1,632 @@
|
|||||||
|
//! Tests for legacy_flat_backup restore compatibility and current backup roundtrip.
|
||||||
|
//!
|
||||||
|
//! These tests use a synthetic fixture that reproduces the exact structure of
|
||||||
|
//! a real legacy_flat_backup archive:
|
||||||
|
//! - admin.db with a users table (TEXT UUID PK, username, password_hash)
|
||||||
|
//! - users.db that is completely empty (no tables, user_version=0)
|
||||||
|
//! - system.db with global_slugs referencing multiple owner_user_ids
|
||||||
|
//! - content.db with URLs and landing pages
|
||||||
|
//! - analytics.db with visits
|
||||||
|
//! - backup_manifest.json with type "legacy_flat_backup"
|
||||||
|
//! - Orphaned slug entries (in global_slugs but not in content.db)
|
||||||
|
//! - A missing tenant (owner_user_id=3 whose databases are not included)
|
||||||
|
|
||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tar::Builder;
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.base_url = Some("http://bzo.in".to_string());
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a synthetic legacy_flat_backup .tar.gz archive that reproduces the
|
||||||
|
/// exact structure of the real production backup that fails with:
|
||||||
|
/// "Failed to verify registry integrity in backup: no such table: users"
|
||||||
|
///
|
||||||
|
/// IMPORTANT: This uses purely synthetic data — no real credentials, URLs,
|
||||||
|
/// audit logs, or analytics from the production backup are included.
|
||||||
|
fn build_synthetic_legacy_fixture(output_path: &std::path::Path) {
|
||||||
|
use chrono::Utc;
|
||||||
|
let fixture_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_fixture_build_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&fixture_dir).unwrap();
|
||||||
|
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// --- admin.db: legacy admin schema with TEXT UUID primary key ---
|
||||||
|
{
|
||||||
|
let conn = Connection::open(fixture_dir.join("admin.db")).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
object_type TEXT,
|
||||||
|
object_id TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert a synthetic admin with a known argon2id hash
|
||||||
|
// (this is NOT a real password hash — it's a valid format placeholder)
|
||||||
|
let admin_hash =
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000";
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||||
|
rusqlite::params![
|
||||||
|
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
"admin",
|
||||||
|
admin_hash,
|
||||||
|
&now
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert an audit log entry
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO audit_logs (id, timestamp, username, action) VALUES (?1, ?2, ?3, ?4);",
|
||||||
|
rusqlite::params!["audit-1", &now, "admin", "LOGIN"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Set user_version to 1 (matching legacy migration state)
|
||||||
|
conn.execute_batch("PRAGMA user_version = 1;").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- system.db: has global_slugs with multiple owners + orphaned entries ---
|
||||||
|
{
|
||||||
|
let mut conn = Connection::open(fixture_dir.join("system.db")).unwrap();
|
||||||
|
// Run system migrations to get the full schema
|
||||||
|
bzod::db::migrations::run_migrations(
|
||||||
|
&mut conn,
|
||||||
|
"system",
|
||||||
|
bzod::db::migrations::SYSTEM_MIGRATIONS,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert global_slugs owned by user_id=1 (content exists)
|
||||||
|
for (slug, target_type, target_id) in &[
|
||||||
|
("abc123", "url", "url-id-1"),
|
||||||
|
("def456", "url", "url-id-2"),
|
||||||
|
("!custom-slug", "url", "url-id-3"),
|
||||||
|
("!test-page", "page", "page-id-1"),
|
||||||
|
("!meeting", "page", "page-id-2"),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||||
|
VALUES (?1, 1, ?2, ?3, ?4, ?5, 'active');",
|
||||||
|
rusqlite::params![slug, target_type, target_id, &now, &now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert global_slugs owned by user_id=3 (tenant NOT included in flat backup)
|
||||||
|
for (slug, target_type, target_id) in &[
|
||||||
|
("xyz789", "url", "user3-url-1"),
|
||||||
|
("!user3-page", "page", "user3-page-1"),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||||
|
VALUES (?1, 3, ?2, ?3, ?4, ?5, 'active');",
|
||||||
|
rusqlite::params![slug, target_type, target_id, &now, &now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert an orphaned slug (user_id=1, content doesn't exist)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||||
|
VALUES ('orphan-slug', 1, 'url', 'nonexistent-id', ?1, ?2, 'active');",
|
||||||
|
rusqlite::params![&now, &now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- users.db: completely empty (no schema, user_version=0) ---
|
||||||
|
{
|
||||||
|
let _conn = Connection::open(fixture_dir.join("users.db")).unwrap();
|
||||||
|
// Intentionally empty — this is the root cause of the original bug
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- content.db: URLs and landing pages belonging to user_id=1 ---
|
||||||
|
{
|
||||||
|
let mut conn = Connection::open(fixture_dir.join("content.db")).unwrap();
|
||||||
|
bzod::db::migrations::run_migrations(
|
||||||
|
&mut conn,
|
||||||
|
"content",
|
||||||
|
bzod::db::migrations::CONTENT_MIGRATIONS,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert URLs
|
||||||
|
for (id, code, dest) in &[
|
||||||
|
("url-id-1", "abc123", "https://example.com/1"),
|
||||||
|
("url-id-2", "def456", "https://example.com/2"),
|
||||||
|
("url-id-3", "!custom-slug", "https://example.com/3"),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO urls (id, code, destination, status, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, 'active', ?4, ?5);",
|
||||||
|
rusqlite::params![id, code, dest, &now, &now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert landing pages
|
||||||
|
for (id, code, title) in &[
|
||||||
|
("page-id-1", "!test-page", "Test Page"),
|
||||||
|
("page-id-2", "!meeting", "Meeting Notes"),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO landing_pages (id, code, slug, title, html_content, state, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?2, ?3, '<h1>Test</h1>', 'published', ?4, ?5);",
|
||||||
|
rusqlite::params![id, code, title, &now, &now],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- analytics.db: visits ---
|
||||||
|
{
|
||||||
|
let mut conn = Connection::open(fixture_dir.join("analytics.db")).unwrap();
|
||||||
|
bzod::db::migrations::run_migrations(
|
||||||
|
&mut conn,
|
||||||
|
"analytics",
|
||||||
|
bzod::db::migrations::ANALYTICS_MIGRATIONS,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert some visits
|
||||||
|
for i in 0..10 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO visits (id, target_type, target_id, timestamp, owner_user_id, ip_address, user_agent, referer, accept_language, country, status_code)
|
||||||
|
VALUES (?1, 'url', 'url-id-1', ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||||
|
rusqlite::params![format!("visit-{}", i), &now, "127.0.0.1", "test-agent", "", "en-US", "US", 200],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- backup_manifest.json ---
|
||||||
|
let manifest = serde_json::json!({
|
||||||
|
"created_at": &now,
|
||||||
|
"type": "legacy_flat_backup",
|
||||||
|
"files_included": ["admin.db", "system.db", "users.db", "content.db", "analytics.db"],
|
||||||
|
"note": "Multi-tenant databases flattened for backward compatibility.",
|
||||||
|
});
|
||||||
|
fs::write(
|
||||||
|
fixture_dir.join("backup_manifest.json"),
|
||||||
|
manifest.to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// --- Package into .tar.gz ---
|
||||||
|
let tar_file = fs::File::create(output_path).unwrap();
|
||||||
|
let enc = GzEncoder::new(tar_file, Compression::default());
|
||||||
|
let mut tar = Builder::new(enc);
|
||||||
|
|
||||||
|
for name in &[
|
||||||
|
"admin.db",
|
||||||
|
"system.db",
|
||||||
|
"users.db",
|
||||||
|
"content.db",
|
||||||
|
"analytics.db",
|
||||||
|
"backup_manifest.json",
|
||||||
|
] {
|
||||||
|
tar.append_path_with_name(fixture_dir.join(name), name)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
tar.into_inner().unwrap().finish().unwrap();
|
||||||
|
let _ = fs::remove_dir_all(&fixture_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Test 1: Legacy flat backup restores without "no such table" error
|
||||||
|
// ==========================================================================
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_flat_backup_restore() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_legacy_restore_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
|
||||||
|
build_synthetic_legacy_fixture(&fixture_path);
|
||||||
|
|
||||||
|
let restore_dir = temp_dir.join("restored_data");
|
||||||
|
fs::create_dir_all(&restore_dir).unwrap();
|
||||||
|
|
||||||
|
// This must succeed — previously it failed with "no such table: users"
|
||||||
|
let result = bzod::cli::restore::perform_restore(&fixture_path, &restore_dir);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Legacy flat backup restore failed: {:?}",
|
||||||
|
result.err()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify multi-tenant directory structure
|
||||||
|
assert!(
|
||||||
|
restore_dir.join("admin/admin.db").exists(),
|
||||||
|
"admin/admin.db missing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
restore_dir.join("admin/system.db").exists(),
|
||||||
|
"admin/system.db missing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
restore_dir.join("admin/users.db").exists(),
|
||||||
|
"admin/users.db missing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
restore_dir.join("users/1/content.db").exists(),
|
||||||
|
"users/1/content.db missing"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
restore_dir.join("users/1/analytics.db").exists(),
|
||||||
|
"users/1/analytics.db missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Test 2: Admin credentials are preserved, not manufactured
|
||||||
|
// ==========================================================================
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_restore_preserves_admin_credentials() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_legacy_creds_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
|
||||||
|
build_synthetic_legacy_fixture(&fixture_path);
|
||||||
|
|
||||||
|
let restore_dir = temp_dir.join("restored_data");
|
||||||
|
fs::create_dir_all(&restore_dir).unwrap();
|
||||||
|
|
||||||
|
bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap();
|
||||||
|
|
||||||
|
let expected_hash =
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000";
|
||||||
|
|
||||||
|
// Verify original admin identity in admin.db is untouched
|
||||||
|
{
|
||||||
|
let conn = Connection::open(restore_dir.join("admin/admin.db")).unwrap();
|
||||||
|
let (username, hash): (String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT username, password_hash FROM users WHERE id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(username, "admin");
|
||||||
|
assert_eq!(hash, expected_hash, "Admin password hash was modified!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify users.db was bootstrapped with actual admin credentials
|
||||||
|
{
|
||||||
|
let conn = Connection::open(restore_dir.join("admin/users.db")).unwrap();
|
||||||
|
|
||||||
|
// legacy_admin system placeholder should exist with id=1
|
||||||
|
let (la_username, la_hash, la_type): (String, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT username, password_hash, account_type FROM users WHERE id = 1;",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(la_username, "legacy_admin");
|
||||||
|
assert_eq!(
|
||||||
|
la_hash, expected_hash,
|
||||||
|
"legacy_admin hash should match original admin"
|
||||||
|
);
|
||||||
|
assert_eq!(la_type, "system");
|
||||||
|
|
||||||
|
// Actual admin account should exist with original credentials
|
||||||
|
let (admin_hash, admin_type, admin_status): (String, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT password_hash, account_type, status FROM users WHERE username = 'admin';",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
admin_hash, expected_hash,
|
||||||
|
"Admin account hash should match original"
|
||||||
|
);
|
||||||
|
assert_eq!(admin_type, "admin");
|
||||||
|
assert_eq!(admin_status, "active");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Test 3: Functional data is preserved and accessible after restore + Db::init()
|
||||||
|
// ==========================================================================
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_legacy_restore_functional_data() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!(
|
||||||
|
"bzod_test_legacy_functional_{}",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
|
||||||
|
build_synthetic_legacy_fixture(&fixture_path);
|
||||||
|
|
||||||
|
let restore_dir = temp_dir.join("restored_data");
|
||||||
|
fs::create_dir_all(&restore_dir).unwrap();
|
||||||
|
|
||||||
|
bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap();
|
||||||
|
|
||||||
|
// Initialize Db against the restored data (simulates fresh v0.6.0 startup)
|
||||||
|
let config = create_temp_config(restore_dir.clone());
|
||||||
|
let db = Db::init(&config).expect("Db::init failed on restored legacy data");
|
||||||
|
|
||||||
|
// Verify URLs
|
||||||
|
{
|
||||||
|
let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap();
|
||||||
|
let url_count: i64 = content_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(url_count, 3, "Expected 3 URLs in restored content.db");
|
||||||
|
|
||||||
|
let url = bzod::db::content::get_url_by_code(&content_conn, "abc123")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(url.destination, "https://example.com/1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify landing pages
|
||||||
|
{
|
||||||
|
let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap();
|
||||||
|
let page_count: i64 = content_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page_count, 2,
|
||||||
|
"Expected 2 landing pages in restored content.db"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify analytics
|
||||||
|
{
|
||||||
|
let analytics_conn = bzod::jobs::open_user_analytics_conn(&db, 1).unwrap();
|
||||||
|
let visit_count: i64 = analytics_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
visit_count, 10,
|
||||||
|
"Expected 10 visits in restored analytics.db"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify global slug registry
|
||||||
|
{
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
let slug_count: i64 = system_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
slug_count >= 7,
|
||||||
|
"Expected at least 7 global_slugs (5 user1 + 2 user3 + orphan)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user 3 placeholder exists
|
||||||
|
{
|
||||||
|
let users_conn = db.users.lock().unwrap();
|
||||||
|
let user3_exists: bool = users_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE id = 3);",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
user3_exists,
|
||||||
|
"Placeholder for user_id=3 should exist in users.db"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (status, metadata): (String, Option<String>) = users_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT status, metadata FROM users WHERE id = 3;",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, "disabled", "User 3 placeholder should be disabled");
|
||||||
|
assert!(
|
||||||
|
metadata.as_deref().unwrap_or("").contains("Placeholder"),
|
||||||
|
"User 3 metadata should document it as a placeholder"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin identity in admin.db
|
||||||
|
{
|
||||||
|
let admin_conn = db.admin.lock().unwrap();
|
||||||
|
let admin_exists: bool = admin_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin');",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
admin_exists,
|
||||||
|
"Original admin identity must be preserved in admin.db"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Test 4: Current/native backup restore roundtrip
|
||||||
|
// ==========================================================================
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_current_backup_restore_roundtrip() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_current_rt_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
|
||||||
|
// Create a user and add content
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("testuser".to_string()),
|
||||||
|
Some("password123".to_string()),
|
||||||
|
None,
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let user_id = {
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
|
||||||
|
bzod::db::content::create_url_extended(
|
||||||
|
&user_content_conn,
|
||||||
|
"!current-test",
|
||||||
|
"https://example.com/current",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&vec![],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let system_conn = db.system.lock().unwrap();
|
||||||
|
bzod::db::users::register_global_slug(
|
||||||
|
&system_conn,
|
||||||
|
"!current-test",
|
||||||
|
user_id,
|
||||||
|
"url",
|
||||||
|
"current-id",
|
||||||
|
"active",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a native backup
|
||||||
|
let backup_path = bzod::jobs::backup::perform_backup(&db, &config)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Restore to a fresh directory
|
||||||
|
let restore_dir = temp_dir.join("restored_native");
|
||||||
|
fs::create_dir_all(&restore_dir).unwrap();
|
||||||
|
|
||||||
|
bzod::cli::restore::perform_restore(std::path::Path::new(&backup_path), &restore_dir).unwrap();
|
||||||
|
|
||||||
|
// Verify restored data
|
||||||
|
let restore_config = create_temp_config(restore_dir.clone());
|
||||||
|
let restored_db = Db::init(&restore_config).expect("Db::init failed on restored native data");
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = restored_db.users.lock().unwrap();
|
||||||
|
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(user.status, "active");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let content_conn = bzod::jobs::open_user_content_conn(&restored_db, user_id).unwrap();
|
||||||
|
let url = bzod::db::content::get_url_by_code(&content_conn, "!current-test")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(url.destination, "https://example.com/current");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Test 5: Failed restore does not corrupt existing data
|
||||||
|
// ==========================================================================
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_failed_restore_does_not_corrupt() {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_test_safe_restore_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
|
||||||
|
// Set up a working installation
|
||||||
|
let _db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
|
||||||
|
// Write a sentinel file to verify the data dir survives
|
||||||
|
let sentinel = config.data_dir.join("admin").join("sentinel.txt");
|
||||||
|
fs::write(&sentinel, "intact").unwrap();
|
||||||
|
|
||||||
|
// Create a corrupt "backup" file
|
||||||
|
let corrupt_path = temp_dir.join("corrupt.tar.gz");
|
||||||
|
fs::write(&corrupt_path, b"this is not a valid tar.gz file").unwrap();
|
||||||
|
|
||||||
|
// Attempt restore — must fail
|
||||||
|
let result = bzod::cli::restore::perform_restore(&corrupt_path, &config.data_dir);
|
||||||
|
assert!(result.is_err(), "Corrupt backup should fail to restore");
|
||||||
|
|
||||||
|
// Verify original data is intact
|
||||||
|
assert!(
|
||||||
|
sentinel.exists(),
|
||||||
|
"Sentinel file must survive failed restore"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(&sentinel).unwrap(),
|
||||||
|
"intact",
|
||||||
|
"Sentinel file content must be unchanged"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
@@ -44,7 +44,9 @@ async fn start_test_server(
|
|||||||
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
||||||
let config = create_temp_config(temp_dir);
|
let config = create_temp_config(temp_dir);
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 100);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ async fn start_test_server(
|
|||||||
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
||||||
let config = create_temp_config(temp_dir);
|
let config = create_temp_config(temp_dir);
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 100);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
|
|||||||
@@ -0,0 +1,743 @@
|
|||||||
|
//! Redirect security & behavioral regression tests (Phase 2).
|
||||||
|
//!
|
||||||
|
//! Covers: 301, legacy malicious destinations, expiration, access limits,
|
||||||
|
//! password gate ordering, previews, tenant slug isolation, concurrent access.
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::Barrier;
|
||||||
|
|
||||||
|
use bzod::analytics::AnalyticsQueue;
|
||||||
|
use bzod::auth::password::hash_password;
|
||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use bzod::services::destination_audit::{
|
||||||
|
audit_all_destinations, audit_content_conn, DestinationAuditReport,
|
||||||
|
};
|
||||||
|
use bzod::state::AppState;
|
||||||
|
use bzod::web::create_router;
|
||||||
|
|
||||||
|
fn compute_sha256(value: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.clone();
|
||||||
|
config.admin_username = "admin".to_string();
|
||||||
|
config.base_url = Some("http://localhost:8080".to_string());
|
||||||
|
config.cookie_secure = false;
|
||||||
|
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_test_server(
|
||||||
|
temp_dir: PathBuf,
|
||||||
|
) -> (reqwest::Client, String, Db, tokio::task::JoinHandle<()>) {
|
||||||
|
let config = create_temp_config(temp_dir);
|
||||||
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
admin_db: db.admin.clone(),
|
||||||
|
content_db: db.content.clone(),
|
||||||
|
analytics_db: db.analytics.clone(),
|
||||||
|
system_db: db.system.clone(),
|
||||||
|
users_db: db.users.clone(),
|
||||||
|
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||||
|
db: db.clone(),
|
||||||
|
config,
|
||||||
|
analytics_queue: queue,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = create_router(state);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let url = format!("http://{}", addr);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(client, url, db, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeedUrl<'a> {
|
||||||
|
owner_user_id: i64,
|
||||||
|
code: &'a str,
|
||||||
|
destination: &'a str,
|
||||||
|
expired: bool,
|
||||||
|
expires_at: Option<&'a str>,
|
||||||
|
password_hash: Option<&'a str>,
|
||||||
|
max_access_count: Option<i64>,
|
||||||
|
access_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_url(db: &Db, seed: SeedUrl<'_>) {
|
||||||
|
// Ensure user content DB exists
|
||||||
|
db.init_user_databases(seed.owner_user_id)
|
||||||
|
.expect("init user dbs");
|
||||||
|
|
||||||
|
{
|
||||||
|
let system = db.system.lock().unwrap();
|
||||||
|
let _ = bzod::db::users::register_global_slug(
|
||||||
|
&system,
|
||||||
|
seed.code,
|
||||||
|
seed.owner_user_id,
|
||||||
|
"url",
|
||||||
|
"seed",
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_path = db
|
||||||
|
.data_dir
|
||||||
|
.join("users")
|
||||||
|
.join(seed.owner_user_id.to_string())
|
||||||
|
.join("content.db");
|
||||||
|
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, max_access_count, access_count)
|
||||||
|
VALUES (?1, ?2, ?3, NULL, NULL, 'healthy', ?4, ?4, ?5, ?6, ?7, ?8, ?9);",
|
||||||
|
rusqlite::params![
|
||||||
|
id,
|
||||||
|
seed.code,
|
||||||
|
seed.destination,
|
||||||
|
now,
|
||||||
|
seed.expires_at,
|
||||||
|
if seed.expired { 1 } else { 0 },
|
||||||
|
seed.password_hash,
|
||||||
|
seed.max_access_count,
|
||||||
|
seed.access_count,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn valid_destination_returns_301() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_301_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "a1b2c3",
|
||||||
|
destination: "https://example.com/target",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client.get(format!("{}/a1b2c3", base)).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
|
||||||
|
assert_eq!(
|
||||||
|
res.headers().get("location").unwrap().to_str().unwrap(),
|
||||||
|
"https://example.com/target"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_crlf_destination_fails_closed_no_location() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_crlf_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
// Simulate legacy DB row that bypassed modern write validation.
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "dead01",
|
||||||
|
destination: "https://evil.example/\r\nX-Injected: yes",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client.get(format!("{}/dead01", base)).send().await.unwrap();
|
||||||
|
// Must not panic; fail closed without Location header.
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert!(res.headers().get("location").is_none());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn javascript_scheme_legacy_fails_closed() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_js_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab1111",
|
||||||
|
destination: "javascript:alert(1)",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client.get(format!("{}/ab1111", base)).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
assert!(res.headers().get("location").is_none());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn expired_flag_returns_410_without_redirect() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_exp_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab2222",
|
||||||
|
destination: "https://example.com/gone",
|
||||||
|
expired: true,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client.get(format!("{}/ab2222", base)).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
assert!(res.headers().get("location").is_none());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wall_clock_expiry_returns_410_without_hot_path_write_dependency() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_exp2_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
// expired=0 but expires_at in the past → still 410 (read-path authoritative).
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab3333",
|
||||||
|
destination: "https://example.com/gone2",
|
||||||
|
expired: false,
|
||||||
|
expires_at: Some("2000-01-01T00:00:00Z"),
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client.get(format!("{}/ab3333", base)).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
// Column may still be 0 until sweeper runs — correctness does not require write.
|
||||||
|
let content_path = db.data_dir.join("users/1/content.db");
|
||||||
|
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||||
|
let expired_flag: i64 = conn
|
||||||
|
.query_row("SELECT expired FROM urls WHERE code = 'ab3333';", [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
// Either 0 (hot path no write) or 1 is acceptable if something else flipped it;
|
||||||
|
// the important assertion is 410 above. Prefer documenting no write:
|
||||||
|
assert!(
|
||||||
|
expired_flag == 0 || expired_flag == 1,
|
||||||
|
"unexpected expired flag {}",
|
||||||
|
expired_flag
|
||||||
|
);
|
||||||
|
// Phase 2 guarantee: no hot-path write required — if still 0, sweeper is maintenance only.
|
||||||
|
assert_eq!(
|
||||||
|
expired_flag, 0,
|
||||||
|
"redirect hot path must not persist expired=1"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn access_limit_exhausted_returns_410() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_lim_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab4444",
|
||||||
|
destination: "https://example.com/limited",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: Some(2),
|
||||||
|
access_count: 2, // already exhausted
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client.get(format!("{}/ab4444", base)).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn password_gate_before_access_increment() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_pw_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
let hash = hash_password("secret-pass").unwrap();
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab5555",
|
||||||
|
destination: "https://example.com/secret",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: Some(&hash),
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let res = client.get(format!("{}/ab5555", base)).send().await.unwrap();
|
||||||
|
assert!(res.status().is_redirection());
|
||||||
|
let loc = res.headers().get("location").unwrap().to_str().unwrap();
|
||||||
|
assert!(loc.contains("/gate/ab5555"));
|
||||||
|
|
||||||
|
// Access count must not have incremented
|
||||||
|
let content_path = db.data_dir.join("users/1/content.db");
|
||||||
|
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT access_count FROM urls WHERE code = 'ab5555';",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_redirects_increment_access_count() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_conc_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab6666",
|
||||||
|
destination: "https://example.com/concurrent",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const N: usize = 20;
|
||||||
|
let barrier = Arc::new(Barrier::new(N));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..N {
|
||||||
|
let client = client.clone();
|
||||||
|
let url = format!("{}/ab6666", base);
|
||||||
|
let b = barrier.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
b.wait().await;
|
||||||
|
client.get(&url).send().await.unwrap()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ok_301 = 0;
|
||||||
|
for h in handles {
|
||||||
|
let res = h.await.unwrap();
|
||||||
|
if res.status() == reqwest::StatusCode::MOVED_PERMANENTLY {
|
||||||
|
ok_301 += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(ok_301, N);
|
||||||
|
|
||||||
|
let content_path = db.data_dir.join("users/1/content.db");
|
||||||
|
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT access_count FROM urls WHERE code = 'ab6666';",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
count, N as i64,
|
||||||
|
"each successful redirect should increment access_count once"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tenant_slug_resolves_owner_not_cross_tenant_content() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_ten_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
// Create two users
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("alice".into()),
|
||||||
|
Some("password123".into()),
|
||||||
|
None,
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("bob".into()),
|
||||||
|
Some("password123".into()),
|
||||||
|
None,
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (alice_id, bob_id) = {
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
let a = bzod::db::users::get_user_by_username(&conn, "alice")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let b = bzod::db::users::get_user_by_username(&conn, "bob")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
(a, b)
|
||||||
|
};
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: alice_id,
|
||||||
|
code: "!alice1",
|
||||||
|
destination: "https://alice.example/ok",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: bob_id,
|
||||||
|
code: "!bob001",
|
||||||
|
destination: "https://bob.example/ok",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let res_a = client
|
||||||
|
.get(format!("{}/!alice1", base))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res_a.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
|
||||||
|
assert_eq!(
|
||||||
|
res_a.headers().get("location").unwrap().to_str().unwrap(),
|
||||||
|
"https://alice.example/ok"
|
||||||
|
);
|
||||||
|
|
||||||
|
let res_b = client
|
||||||
|
.get(format!("{}/!bob001", base))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res_b.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
|
||||||
|
assert_eq!(
|
||||||
|
res_b.headers().get("location").unwrap().to_str().unwrap(),
|
||||||
|
"https://bob.example/ok"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn destination_audit_finds_legacy_invalid_without_rewriting() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_audit_dest_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
let db = Db::init(&config).unwrap();
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab9999",
|
||||||
|
destination: "https://example.com/good",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "bad001",
|
||||||
|
destination: "https://evil/\r\nX:1",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "bad002",
|
||||||
|
destination: "javascript:alert(1)",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = audit_all_destinations(&db).unwrap();
|
||||||
|
assert_eq!(report.total_urls, 3);
|
||||||
|
assert_eq!(report.valid_https, 1);
|
||||||
|
assert_eq!(report.invalid, 2);
|
||||||
|
assert_eq!(report.control_characters, 1);
|
||||||
|
assert_eq!(report.unsupported_scheme, 1);
|
||||||
|
|
||||||
|
// Data not rewritten
|
||||||
|
let content_path = db.data_dir.join("users/1/content.db");
|
||||||
|
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||||
|
let dest: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT destination FROM urls WHERE code = 'bad001';",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(dest.contains('\r'));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disabled_slug_returns_410() {
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_dis_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab7777",
|
||||||
|
destination: "https://example.com/x",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let system = db.system.lock().unwrap();
|
||||||
|
system
|
||||||
|
.execute(
|
||||||
|
"UPDATE global_slugs SET status = 'disabled' WHERE slug = 'ab7777';",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = client.get(format!("{}/ab7777", base)).send().await.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::GONE);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redirect_performance_smoke() {
|
||||||
|
// Not a CI gate for absolute latency — documents methodology and asserts
|
||||||
|
// correctness under concurrent load (error rate = 0, access counts match).
|
||||||
|
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_perf_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
|
||||||
|
|
||||||
|
seed_url(
|
||||||
|
&db,
|
||||||
|
SeedUrl {
|
||||||
|
owner_user_id: 1,
|
||||||
|
code: "ab8888",
|
||||||
|
destination: "https://example.com/perf",
|
||||||
|
expired: false,
|
||||||
|
expires_at: None,
|
||||||
|
password_hash: None,
|
||||||
|
max_access_count: None,
|
||||||
|
access_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Warm-up
|
||||||
|
for _ in 0..10 {
|
||||||
|
let _ = client.get(format!("{}/ab8888", base)).send().await.unwrap();
|
||||||
|
}
|
||||||
|
// Reset access count after warm-up for clean correctness check
|
||||||
|
{
|
||||||
|
let content_path = db.data_dir.join("users/1/content.db");
|
||||||
|
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE urls SET access_count = 0 WHERE code = 'ab8888';",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUESTS: usize = 200;
|
||||||
|
const CONCURRENCY: usize = 20;
|
||||||
|
let mut latencies_ms: Vec<f64> = Vec::with_capacity(REQUESTS);
|
||||||
|
let mut errors = 0usize;
|
||||||
|
|
||||||
|
let mut remaining = REQUESTS;
|
||||||
|
while remaining > 0 {
|
||||||
|
let batch = remaining.min(CONCURRENCY);
|
||||||
|
let mut handles = Vec::with_capacity(batch);
|
||||||
|
for _ in 0..batch {
|
||||||
|
let client = client.clone();
|
||||||
|
let url = format!("{}/ab8888", base);
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let start = Instant::now();
|
||||||
|
let res = client.get(&url).send().await;
|
||||||
|
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
(res, elapsed)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
match h.await.unwrap() {
|
||||||
|
(Ok(res), ms) if res.status() == reqwest::StatusCode::MOVED_PERMANENTLY => {
|
||||||
|
latencies_ms.push(ms);
|
||||||
|
}
|
||||||
|
_ => errors += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining -= batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
latencies_ms.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let p = |q: f64| {
|
||||||
|
let idx = ((latencies_ms.len() as f64 - 1.0) * q).round() as usize;
|
||||||
|
latencies_ms[idx]
|
||||||
|
};
|
||||||
|
let p50 = p(0.50);
|
||||||
|
let p95 = p(0.95);
|
||||||
|
let p99 = p(0.99);
|
||||||
|
let throughput = REQUESTS as f64
|
||||||
|
/ (latencies_ms.iter().sum::<f64>() / CONCURRENCY as f64 / 1000.0).max(0.001);
|
||||||
|
|
||||||
|
eprintln!("=== redirect_performance_smoke ===");
|
||||||
|
eprintln!("env: local loopback, SQLite WAL, warm connections");
|
||||||
|
eprintln!("requests={}, concurrency={}", REQUESTS, CONCURRENCY);
|
||||||
|
eprintln!(
|
||||||
|
"p50={:.3}ms p95={:.3}ms p99={:.3}ms errors={} approx_rps={:.1}",
|
||||||
|
p50, p95, p99, errors, throughput
|
||||||
|
);
|
||||||
|
eprintln!("baseline comparison: UNAVAILABLE (no git history in workspace)");
|
||||||
|
|
||||||
|
assert_eq!(errors, 0, "error rate must be zero");
|
||||||
|
assert_eq!(latencies_ms.len(), REQUESTS);
|
||||||
|
|
||||||
|
let content_path = db.data_dir.join("users/1/content.db");
|
||||||
|
let conn = rusqlite::Connection::open(content_path).unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT access_count FROM urls WHERE code = 'ab8888';",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, REQUESTS as i64);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audit_content_conn_unit_path() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE urls (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
destination TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO urls VALUES ('1','a','https://ok.example/');",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO urls VALUES ('2','b','javascript:x');", [])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut report = DestinationAuditReport::default();
|
||||||
|
audit_content_conn(&conn, 9, &mut report).unwrap();
|
||||||
|
assert_eq!(report.total_urls, 2);
|
||||||
|
assert_eq!(report.valid_https, 1);
|
||||||
|
assert_eq!(report.unsupported_scheme, 1);
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ async fn test_global_slug_lookup_and_redirection() {
|
|||||||
let config = create_temp_config(temp_dir.clone());
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
@@ -120,7 +122,9 @@ async fn test_disabled_slug_returns_410() {
|
|||||||
let config = create_temp_config(temp_dir.clone());
|
let config = create_temp_config(temp_dir.clone());
|
||||||
|
|
||||||
let db = Db::init(&config).expect("Failed to init Db");
|
let db = Db::init(&config).expect("Failed to init Db");
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ async fn test_upgrade_from_v0_4_0() {
|
|||||||
assert!(temp_dir.join("users/1/analytics.db").exists());
|
assert!(temp_dir.join("users/1/analytics.db").exists());
|
||||||
|
|
||||||
// Spawn server
|
// Spawn server
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 10);
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
Box::leak(Box::new(tx));
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
content_db: db.content.clone(),
|
content_db: db.content.clone(),
|
||||||
|
|||||||
Reference in new issue
Block a user