release: finalize BZOD v0.8.0
This commit is contained in:
1 parent
d7e0ac7679
commit
d398341f01
35 files changed
+2417
-525
No files matched your search
Generated
+1
-1
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
version = "0.7.0"
|
version = "0.8.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.7.0"
|
version = "0.8.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)
|
||||||
@@ -93,7 +93,7 @@ No recurring subscription fees.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Runtime Efficiency (v0.7.0)
|
## Runtime Efficiency (v0.8.0)
|
||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---------|------:|
|
|---------|------:|
|
||||||
@@ -1337,7 +1337,7 @@ Community feedback helps guide future development.
|
|||||||
|
|
||||||
**Current Version**
|
**Current Version**
|
||||||
|
|
||||||
**v0.7.0**
|
**v0.8.0**
|
||||||
|
|
||||||
Production Ready
|
Production Ready
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
# sudo bash deploy.sh
|
# sudo bash deploy.sh
|
||||||
#
|
#
|
||||||
# Environment overrides:
|
# Environment overrides:
|
||||||
# BZOD_VERSION=0.7.1
|
# BZOD_VERSION=0.8.0
|
||||||
# BZOD_IMAGE=nx9-url-shortener
|
# BZOD_IMAGE=nx9-url-shortener
|
||||||
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
|
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
|
||||||
# BZOD_PORT=8654
|
# BZOD_PORT=8654
|
||||||
@@ -23,7 +23,7 @@ set -euo pipefail
|
|||||||
# Configuration
|
# Configuration
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
BZOD_VERSION="${BZOD_VERSION:-0.7.1}"
|
BZOD_VERSION="${BZOD_VERSION:-0.8.0}"
|
||||||
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
|
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
|
||||||
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
|
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
|
||||||
BZOD_PORT="${BZOD_PORT:-8654}"
|
BZOD_PORT="${BZOD_PORT:-8654}"
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ services:
|
|||||||
|
|
||||||
hostname: bzod
|
hostname: bzod
|
||||||
|
|
||||||
image: nx9-url-shortener:v0.7.0
|
image: nx9-url-shortener:v0.8.0
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- mode: ingress
|
- mode: ingress
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Administrator Guide
|
# BZOD Administrator Guide
|
||||||
|
|
||||||
Version: v0.7.0
|
Version: v0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Architecture Guide
|
# BZOD Architecture Guide
|
||||||
|
|
||||||
Version: v0.7.0
|
Version: v0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backup & Restore Guide
|
# Backup & Restore Guide
|
||||||
|
|
||||||
Version: v0.7.0
|
Version: v0.8.0
|
||||||
Applies To: BZOD Multi-User Platform
|
Applies To: BZOD Multi-User Platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on Keep a Changelog and this project follows Semantic Versioning.
|
The format is based on Keep a Changelog and this project follows Semantic Versioning.
|
||||||
|
|
||||||
|
# v0.8.0 — Core Admin Separation, Tenant Boundary & Authentication Hardening
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
* Core Admin is strictly platform-operator-only and has no tenant application storage.
|
||||||
|
* Inspection-only global URL and landing-page registries for Admin.
|
||||||
|
* Strict Admin/tenant route boundary with HTTP 403 enforcement.
|
||||||
|
* TenantId-based active ownership and tenant filesystem topology.
|
||||||
|
* Tenant-aware analytics worker grouping.
|
||||||
|
* Deterministic cross-role session invalidation and cookie clearing.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
* Removed active production dependencies on the legacy `system.db.global_slugs` registry.
|
||||||
|
* Removed request-time TenantId generation and integer tenant filesystem fallbacks from active tenant operations.
|
||||||
|
* Admin resource creation endpoints reject Core Admin actors with `403 Forbidden`.
|
||||||
|
* User login and Admin login now establish role-specific sessions and clear the opposite-role session.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
* Historical v0.7.x migration and legacy restore compatibility remains preserved.
|
||||||
|
* Legacy database/schema identifiers are retained only where required for migration and historical restore support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||||
|
|||||||
+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.7.0 is:
|
The current command list for BZOD v0.8.0 is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
$ bzod --help
|
$ bzod --help
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# BZOD v0.7.0 vs Self-Hosted URL Management Platforms
|
# BZOD v0.8.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.7.0 uses SQLite exclusively.
|
BZOD v0.8.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.7.0
|
Version: v0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,13 +183,13 @@ sudo pacman -S \
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz
|
wget https://example.com/bzod-v0.8.0-linux-amd64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
Extract:
|
Extract:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
|
tar -xzf bzod-v0.8.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.7.0
|
Version: v0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
# BZOD v0.8.0 — Multi-Tenant Core Separation & Authorization Hardening
|
||||||
|
|
||||||
|
Release Date: 2026-08-21
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Core Admin separation**: Admin is a platform operator, not a tenant and not an application resource owner.
|
||||||
|
- **Global slug registries**: Active URL and landing-page ownership uses `slugs/global_urls.db` and `slugs/global_landing_pages.db`.
|
||||||
|
- **Strict route boundary**: Core Admin is forbidden from `/user/*`; normal tenant users are forbidden from `/admin/*`.
|
||||||
|
- **Capability enforcement**: Admin resource creation through UI and REST/bulk endpoints returns `403 Forbidden`.
|
||||||
|
- **Tenant identity hardening**: Active tenant operations require an immutable `TenantId`; no request-time fallback generation or `users/1` application fallback.
|
||||||
|
- **Session hygiene**: Admin/user session cookies and server-side sessions are invalidated when switching principals or logging out.
|
||||||
|
- **Tenant-aware analytics**: Analytics events are grouped and persisted by `TenantId`.
|
||||||
|
- **Legacy compatibility preserved**: Legacy migration and restore paths remain available without being active production paths.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Phase 5 Core Separation tests: **4/4 passed**
|
||||||
|
- Admin capability boundary tests: **11/11 passed**
|
||||||
|
- Admin/user route and session boundary tests: **27/27 passed**
|
||||||
|
- Phase 6A elimination tests: **6/6 passed**
|
||||||
|
- Workspace regression suite: **all tests passed**
|
||||||
|
- `cargo fmt --all -- --check`: **PASS**
|
||||||
|
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: **PASS**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||||
|
|
||||||
Release Date: 2026-08-11
|
Release Date: 2026-08-11
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Security Guide
|
# BZOD Security Guide
|
||||||
|
|
||||||
Version: v0.7.0
|
Version: v0.8.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.7.0.
|
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.8.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -620,7 +620,7 @@ If compromise is suspected:
|
|||||||
|
|
||||||
# Security Testing
|
# Security Testing
|
||||||
|
|
||||||
BZOD v0.7.0 includes tests covering:
|
BZOD v0.8.0 includes tests covering:
|
||||||
|
|
||||||
* Authentication
|
* Authentication
|
||||||
* Authorization
|
* Authorization
|
||||||
@@ -665,7 +665,7 @@ These may be addressed in future releases.
|
|||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
|
|
||||||
BZOD v0.7.0 provides:
|
BZOD v0.8.0 provides:
|
||||||
|
|
||||||
* Centralized authentication
|
* Centralized authentication
|
||||||
* Secure session management
|
* Secure session management
|
||||||
|
|||||||
+14
-2
@@ -1,11 +1,23 @@
|
|||||||
# Upgrade Guide
|
# Upgrade Guide
|
||||||
|
|
||||||
Version: v0.7.0
|
Version: v0.8.0
|
||||||
|
|
||||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0.
|
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.8.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# BZOD v0.8.0 Upgrade Overview
|
||||||
|
|
||||||
|
BZOD v0.8.0 completes the TenantId-based multi-tenant topology and separates Core Admin from tenant application resources. The active runtime uses the Core databases under `admin/`, global slug registries under `slugs/`, and tenant databases under `users/<TenantId>/`.
|
||||||
|
|
||||||
|
Key upgrade characteristics:
|
||||||
|
|
||||||
|
* Core Admin has no tenant directory, `content.db`, or `analytics.db`.
|
||||||
|
* Active production operations no longer use `system.db.global_slugs`.
|
||||||
|
* Active tenant ownership is represented by immutable `TenantId`.
|
||||||
|
* Legacy integer IDs and legacy slug data remain available only to migration/restore compatibility paths.
|
||||||
|
* Existing legacy deployments should use the repository's migration and restore commands rather than manually copying legacy tenant directories into the v0.8 topology.
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
|
|
||||||
BZOD v0.5.1 is a platform hardening release focused on:
|
BZOD v0.5.1 is a platform hardening release focused on:
|
||||||
|
|||||||
+4
-4
@@ -198,9 +198,9 @@ pub fn authenticate_user_session(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tenant user (status must be 'active')
|
// Get tenant user (status must be 'active', account_type != 'admin', and tenant_id is Some)
|
||||||
let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)?
|
let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)?
|
||||||
.filter(|u| u.status == "active");
|
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
|
||||||
|
|
||||||
if let Some(user) = user_opt {
|
if let Some(user) = user_opt {
|
||||||
Ok(Some((user, session.id)))
|
Ok(Some((user, session.id)))
|
||||||
@@ -234,8 +234,8 @@ pub fn authenticate_api_key(
|
|||||||
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
|
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
|
||||||
|
|
||||||
if let Some(user_id) = user_id_opt {
|
if let Some(user_id) = user_id_opt {
|
||||||
let user_opt =
|
let user_opt = crate::db::users::get_user_by_id(users_conn, user_id)?
|
||||||
crate::db::users::get_user_by_id(users_conn, user_id)?.filter(|u| u.status == "active");
|
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
|
||||||
|
|
||||||
if let Some(user) = user_opt {
|
if let Some(user) = user_opt {
|
||||||
return Ok(Some(ApiActor::User(user)));
|
return Ok(Some(ApiActor::User(user)));
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ use axum::{
|
|||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct AdminPageRow {
|
||||||
|
pub page: LandingPage,
|
||||||
|
pub owner_tenant_id: String,
|
||||||
|
pub owner_username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "pages.html")]
|
#[template(path = "pages.html")]
|
||||||
pub struct PagesTemplate {
|
pub struct PagesTemplate {
|
||||||
pub admin_username: String,
|
pub admin_username: String,
|
||||||
pub pages: Vec<LandingPage>,
|
pub pages: Vec<AdminPageRow>,
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub current_page: usize,
|
pub current_page: usize,
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ use axum::{
|
|||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct AdminUrlRow {
|
||||||
|
pub url: Url,
|
||||||
|
pub owner_tenant_id: String,
|
||||||
|
pub owner_username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "urls.html")]
|
#[template(path = "urls.html")]
|
||||||
pub struct UrlsTemplate {
|
pub struct UrlsTemplate {
|
||||||
pub admin_username: String,
|
pub admin_username: String,
|
||||||
pub urls: Vec<Url>,
|
pub urls: Vec<AdminUrlRow>,
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub tag_filter: Option<String>,
|
pub tag_filter: Option<String>,
|
||||||
|
|||||||
+100
-29
@@ -116,6 +116,31 @@ fn audit_meta(ip: &str, headers: &HeaderMap) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_admin_cookie(jar: CookieJar) -> CookieJar {
|
||||||
|
let cookie = Cookie::build("bzod_session")
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::ZERO)
|
||||||
|
.build();
|
||||||
|
jar.add(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_user_cookie(jar: CookieJar) -> CookieJar {
|
||||||
|
let cookie = Cookie::build("bzod_user_session")
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::ZERO)
|
||||||
|
.build();
|
||||||
|
jar.add(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn invalidate_session_in_db(state: &AppState, session_id: &str) {
|
||||||
|
if session_id.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(conn) = state.users_db.lock() {
|
||||||
|
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [session_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST /admin/login
|
// POST /admin/login
|
||||||
pub async fn login_post(
|
pub async fn login_post(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -233,6 +258,14 @@ pub async fn login_post(
|
|||||||
let session_token = generate_token(32);
|
let session_token = generate_token(32);
|
||||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||||
|
|
||||||
|
// Invalidate any existing sessions from the jar
|
||||||
|
if let Some(old_admin_cookie) = jar.get("bzod_session") {
|
||||||
|
invalidate_session_in_db(&state, old_admin_cookie.value());
|
||||||
|
}
|
||||||
|
if let Some(old_user_cookie) = jar.get("bzod_user_session") {
|
||||||
|
invalidate_session_in_db(&state, old_user_cookie.value());
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let conn = match state.users_db.lock() {
|
let conn = match state.users_db.lock() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -279,6 +312,7 @@ pub async fn login_post(
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut response_jar = jar.clone();
|
let mut response_jar = jar.clone();
|
||||||
|
response_jar = clear_user_cookie(response_jar);
|
||||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||||
|
|
||||||
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||||
@@ -301,14 +335,17 @@ pub async fn login_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /logout
|
// GET /logout
|
||||||
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response {
|
pub async fn public_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
let cookie = Cookie::build("bzod_user_session")
|
if let Some(user_cookie) = jar.get("bzod_user_session") {
|
||||||
.path("/")
|
invalidate_session_in_db(&state, user_cookie.value());
|
||||||
.max_age(time::Duration::ZERO)
|
}
|
||||||
.build();
|
if let Some(admin_cookie) = jar.get("bzod_session") {
|
||||||
|
invalidate_session_in_db(&state, admin_cookie.value());
|
||||||
|
}
|
||||||
|
|
||||||
let mut response_jar = jar.clone();
|
let mut response_jar = jar.clone();
|
||||||
response_jar = response_jar.add(cookie);
|
response_jar = clear_user_cookie(response_jar);
|
||||||
|
response_jar = clear_admin_cookie(response_jar);
|
||||||
|
|
||||||
(response_jar, Redirect::to("/login")).into_response()
|
(response_jar, Redirect::to("/login")).into_response()
|
||||||
}
|
}
|
||||||
@@ -383,6 +420,14 @@ pub async fn public_login_post(
|
|||||||
let session_token = generate_token(32);
|
let session_token = generate_token(32);
|
||||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||||
|
|
||||||
|
// Invalidate any existing sessions from the jar
|
||||||
|
if let Some(old_admin_cookie) = jar.get("bzod_session") {
|
||||||
|
invalidate_session_in_db(&state, old_admin_cookie.value());
|
||||||
|
}
|
||||||
|
if let Some(old_user_cookie) = jar.get("bzod_user_session") {
|
||||||
|
invalidate_session_in_db(&state, old_user_cookie.value());
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let conn = state.users_db.lock().unwrap();
|
let conn = state.users_db.lock().unwrap();
|
||||||
let _ =
|
let _ =
|
||||||
@@ -406,23 +451,51 @@ pub async fn public_login_post(
|
|||||||
|
|
||||||
let secure_flag =
|
let secure_flag =
|
||||||
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
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")
|
if user.account_type == "admin" {
|
||||||
.path("/login")
|
let cookie = Cookie::build(("bzod_session", session_token))
|
||||||
.max_age(time::Duration::ZERO)
|
.path("/")
|
||||||
.build();
|
.secure(secure_flag)
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||||
|
.max_age(time::Duration::days(30))
|
||||||
|
.build();
|
||||||
|
|
||||||
let mut response_jar = jar.clone();
|
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
.path("/login")
|
||||||
|
.max_age(time::Duration::ZERO)
|
||||||
|
.build();
|
||||||
|
|
||||||
(response_jar, Redirect::to("/user/dashboard")).into_response()
|
let mut response_jar = jar.clone();
|
||||||
|
response_jar = clear_user_cookie(response_jar);
|
||||||
|
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||||
|
|
||||||
|
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||||
|
} else {
|
||||||
|
if user.tenant_id.is_none() {
|
||||||
|
return Redirect::to("/login?error=Invalid tenant configuration")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = clear_admin_cookie(response_jar);
|
||||||
|
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||||
|
|
||||||
|
(response_jar, Redirect::to("/user/dashboard")).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let system_conn = state.system_db.lock().unwrap();
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
@@ -446,18 +519,16 @@ pub async fn public_login_post(
|
|||||||
|
|
||||||
// GET /admin/logout
|
// GET /admin/logout
|
||||||
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
|
if let Some(admin_cookie) = jar.get("bzod_session") {
|
||||||
let conn = state.users_db.lock().unwrap();
|
invalidate_session_in_db(&state, admin_cookie.value());
|
||||||
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]);
|
}
|
||||||
|
if let Some(user_cookie) = jar.get("bzod_user_session") {
|
||||||
|
invalidate_session_in_db(&state, user_cookie.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
let cookie = Cookie::build("bzod_session")
|
|
||||||
.path("/")
|
|
||||||
.max_age(time::Duration::ZERO)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let mut response_jar = jar.clone();
|
let mut response_jar = jar.clone();
|
||||||
response_jar = response_jar.add(cookie);
|
response_jar = clear_admin_cookie(response_jar);
|
||||||
|
response_jar = clear_user_cookie(response_jar);
|
||||||
|
|
||||||
(response_jar, Redirect::to("/admin/login")).into_response()
|
(response_jar, Redirect::to("/admin/login")).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-9
@@ -86,32 +86,74 @@ pub(crate) fn write_audit_log(
|
|||||||
pub(crate) const PAGE_SIZE: usize = 25;
|
pub(crate) const PAGE_SIZE: usize = 25;
|
||||||
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
|
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
|
||||||
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
|
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
|
||||||
// Helper: Verify admin session and return user or redirect to login
|
// Helper: Verify admin session and return user or error response (403 Forbidden / login redirect)
|
||||||
pub(crate) async fn require_auth(
|
pub(crate) async fn require_auth(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
jar: &CookieJar,
|
jar: &CookieJar,
|
||||||
) -> Result<(User, String), Redirect> {
|
) -> Result<(User, String), Response> {
|
||||||
let conn = match state.users_db.lock() {
|
let conn = match state.users_db.lock() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return Err(Redirect::to("/admin/login")),
|
Err(_) => return Err(Redirect::to("/admin/login").into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
match authenticate_admin_session(&conn, jar) {
|
match authenticate_admin_session(&conn, jar) {
|
||||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||||
_ => Err(Redirect::to("/admin/login")),
|
Ok(None) => {
|
||||||
|
// Check if user is logged in as a normal tenant user trying to access admin route
|
||||||
|
if let Ok(Some((tenant_user, _))) = authenticate_user_session(&conn, jar) {
|
||||||
|
if tenant_user.account_type != "admin" {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Forbidden: Standard users cannot access Admin routes",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Redirect::to("/admin/login").into_response())
|
||||||
|
}
|
||||||
|
Err(_) => Err(Redirect::to("/admin/login").into_response()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Helper: Verify tenant user session and return user or redirect to login
|
|
||||||
|
// Helper: Verify tenant user session and return tenant user or error response (403 Forbidden / login redirect)
|
||||||
pub(crate) async fn require_user_auth(
|
pub(crate) async fn require_user_auth(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
jar: &CookieJar,
|
jar: &CookieJar,
|
||||||
) -> Result<(crate::models::TenantUser, String), Redirect> {
|
) -> Result<(crate::models::TenantUser, String), Response> {
|
||||||
let conn = match state.users_db.lock() {
|
let conn = match state.users_db.lock() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return Err(Redirect::to("/login")),
|
Err(_) => return Err(Redirect::to("/login").into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If an Admin session is present, Core Admin is trying to access /user/* routes -> Reject with 403 Forbidden
|
||||||
|
if let Ok(Some((_admin_user, _))) = authenticate_admin_session(&conn, jar) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Forbidden: Core Admin cannot access tenant application routes; use /admin/...",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now check tenant user session
|
||||||
match authenticate_user_session(&conn, jar) {
|
match authenticate_user_session(&conn, jar) {
|
||||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
Ok(Some((user, session_id))) => {
|
||||||
_ => Err(Redirect::to("/login")),
|
if user.account_type == "admin" {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Forbidden: Core Admin cannot access tenant application routes; use /admin/...",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
if user.tenant_id.is_none() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Forbidden: User has no assigned TenantId",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
Ok((user, session_id))
|
||||||
|
}
|
||||||
|
_ => Err(Redirect::to("/login").into_response()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
+29
-9
@@ -118,9 +118,10 @@ pub async fn user_pages_create(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let owner_tid = user
|
let owner_tid = match user.tenant_id {
|
||||||
.tenant_id
|
Some(tid) => tid,
|
||||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
None => return (StatusCode::FORBIDDEN, "Missing tenant identity").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||||
@@ -319,10 +320,10 @@ pub async fn pages_get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(p) = resolved_page {
|
let page = if let Some(p) = resolved_page {
|
||||||
pages.push(p);
|
p
|
||||||
} else {
|
} else {
|
||||||
pages.push(crate::models::LandingPage {
|
crate::models::LandingPage {
|
||||||
id: target_id,
|
id: target_id,
|
||||||
code: slug.clone(),
|
code: slug.clone(),
|
||||||
slug,
|
slug,
|
||||||
@@ -331,8 +332,23 @@ pub async fn pages_get(
|
|||||||
state: status,
|
state: status,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
});
|
}
|
||||||
}
|
};
|
||||||
|
let owner_username = {
|
||||||
|
let u_conn = state.users_db.lock().unwrap();
|
||||||
|
u_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT username FROM users WHERE tenant_id = ?1;",
|
||||||
|
[&owner_tid_str],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
};
|
||||||
|
pages.push(crate::templates::pages::AdminPageRow {
|
||||||
|
page,
|
||||||
|
owner_tenant_id: owner_tid_str,
|
||||||
|
owner_username,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_page = current_page.saturating_sub(3).max(1);
|
let start_page = current_page.saturating_sub(3).max(1);
|
||||||
@@ -382,7 +398,11 @@ pub async fn pages_create(
|
|||||||
Err(redir) => return redir.into_response(),
|
Err(redir) => return redir.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Redirect::to("/admin/pages?error=Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account").into_response()
|
(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/pages/delete/:id
|
// POST /admin/pages/delete/:id
|
||||||
|
|||||||
@@ -253,9 +253,7 @@ pub async fn user_restore_backup_post(
|
|||||||
let mut archive = tar::Archive::new(tar_gz);
|
let mut archive = tar::Archive::new(tar_gz);
|
||||||
archive.unpack(&temp_unpack_dir)?;
|
archive.unpack(&temp_unpack_dir)?;
|
||||||
|
|
||||||
let target_tenant_id = user
|
let target_tenant_id = user.tenant_id.ok_or("User is missing assigned TenantId")?;
|
||||||
.tenant_id
|
|
||||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
|
||||||
let temp_content_db = temp_unpack_dir.join("content.db");
|
let temp_content_db = temp_unpack_dir.join("content.db");
|
||||||
if !temp_content_db.exists() {
|
if !temp_content_db.exists() {
|
||||||
return Err("Backup is missing content.db".into());
|
return Err("Backup is missing content.db".into());
|
||||||
|
|||||||
+29
-9
@@ -153,9 +153,10 @@ pub async fn user_urls_create(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let owner_tid = user
|
let owner_tid = match user.tenant_id {
|
||||||
.tenant_id
|
Some(tid) => tid,
|
||||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
None => return (StatusCode::FORBIDDEN, "Missing tenant identity").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||||
@@ -415,10 +416,10 @@ pub async fn urls_get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(u) = resolved_url {
|
let url = if let Some(u) = resolved_url {
|
||||||
urls.push(u);
|
u
|
||||||
} else {
|
} else {
|
||||||
urls.push(crate::models::Url {
|
crate::models::Url {
|
||||||
id: target_id,
|
id: target_id,
|
||||||
code: slug,
|
code: slug,
|
||||||
destination: String::new(),
|
destination: String::new(),
|
||||||
@@ -435,8 +436,23 @@ pub async fn urls_get(
|
|||||||
expired: false,
|
expired: false,
|
||||||
last_latency_ms: None,
|
last_latency_ms: None,
|
||||||
last_status: None,
|
last_status: None,
|
||||||
});
|
}
|
||||||
}
|
};
|
||||||
|
let owner_username = {
|
||||||
|
let u_conn = state.users_db.lock().unwrap();
|
||||||
|
u_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT username FROM users WHERE tenant_id = ?1;",
|
||||||
|
[&owner_tid_str],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
};
|
||||||
|
urls.push(crate::templates::urls::AdminUrlRow {
|
||||||
|
url,
|
||||||
|
owner_tenant_id: owner_tid_str,
|
||||||
|
owner_username,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_page = current_page.saturating_sub(3).max(1);
|
let start_page = current_page.saturating_sub(3).max(1);
|
||||||
@@ -505,7 +521,11 @@ pub async fn urls_create(
|
|||||||
Err(redir) => return redir.into_response(),
|
Err(redir) => return redir.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Redirect::to("/admin/urls?error=Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account").into_response()
|
(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/urls/delete/:id
|
// POST /admin/urls/delete/:id
|
||||||
|
|||||||
+26
-8
@@ -164,7 +164,7 @@ pub async fn api_create_url(
|
|||||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||||
crate::models::ApiActor::Admin(_) => {
|
crate::models::ApiActor::Admin(_) => {
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::FORBIDDEN,
|
||||||
Json(ApiError {
|
Json(ApiError {
|
||||||
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
||||||
}),
|
}),
|
||||||
@@ -184,9 +184,18 @@ pub async fn api_create_url(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let tid = u
|
let tid = match u.tenant_id {
|
||||||
.tenant_id
|
Some(t) => t,
|
||||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "User has no assigned TenantId".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
(u.id, tid, user_dbs.content.clone())
|
(u.id, tid, user_dbs.content.clone())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -560,7 +569,7 @@ pub async fn api_create_page(
|
|||||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||||
crate::models::ApiActor::Admin(_) => {
|
crate::models::ApiActor::Admin(_) => {
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::FORBIDDEN,
|
||||||
Json(ApiError {
|
Json(ApiError {
|
||||||
error: "Admin is a platform operator and cannot create application landing pages directly without tenant context".to_string(),
|
error: "Admin is a platform operator and cannot create application landing pages directly without tenant context".to_string(),
|
||||||
}),
|
}),
|
||||||
@@ -580,9 +589,18 @@ pub async fn api_create_page(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let tid = u
|
let tid = match u.tenant_id {
|
||||||
.tenant_id
|
Some(t) => t,
|
||||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(ApiError {
|
||||||
|
error: "User has no assigned TenantId".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
(u.id, tid, user_dbs.content.clone())
|
(u.id, tid, user_dbs.content.clone())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -217,7 +217,7 @@ pub async fn api_bulk_url(
|
|||||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||||
crate::models::ApiActor::Admin(_) => {
|
crate::models::ApiActor::Admin(_) => {
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::FORBIDDEN,
|
||||||
Json(BulkErrorResponse {
|
Json(BulkErrorResponse {
|
||||||
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1476,13 +1476,13 @@
|
|||||||
<li class="{% block active_urls %}{% endblock %}">
|
<li class="{% block active_urls %}{% endblock %}">
|
||||||
<a href="/admin/urls">
|
<a href="/admin/urls">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
Short URLs
|
URL Registry
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="{% block active_pages %}{% endblock %}">
|
<li class="{% block active_pages %}{% endblock %}">
|
||||||
<a href="/admin/pages">
|
<a href="/admin/pages">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
Landing Pages
|
Landing Page Registry
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="{% block active_users %}{% endblock %}">
|
<li class="{% block active_users %}{% endblock %}">
|
||||||
|
|||||||
+118
-153
@@ -1,10 +1,10 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block title %}Manage Landing Pages - BZOD{% endblock %}
|
{% block title %}Global Landing Page Registry - BZOD Admin{% endblock %}
|
||||||
|
|
||||||
{% block active_pages %}active{% endblock %}
|
{% block active_pages %}active{% endblock %}
|
||||||
|
|
||||||
{% block header_title %}Landing Page Registry{% endblock %}
|
{% block header_title %}Global Landing Page Registry{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if let Some(err) = error %}
|
{% if let Some(err) = error %}
|
||||||
@@ -13,169 +13,134 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="urls-dashboard">
|
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||||
<div class="card">
|
<div class="registered-links-header">
|
||||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
Create a New Landing Page
|
Global Landing Page Registry
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form action="/admin/pages/create" method="POST">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
|
|
||||||
<div class="page-form-grid-2">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="title">Page Title *</label>
|
|
||||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="slug">SEO Slug *</label>
|
|
||||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page-form-grid-3">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="code">Short Code (4-Hex, optional)</label>
|
|
||||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="custom_slug">Custom Slug (optional)</label>
|
|
||||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="state">Publish State</label>
|
|
||||||
<select id="state" name="state" class="form-input">
|
|
||||||
<option value="draft">Draft</option>
|
|
||||||
<option value="published" selected>Published</option>
|
|
||||||
<option value="archived">Archived</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="html_content">Raw HTML Document *</label>
|
|
||||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
<div class="table-container">
|
||||||
<div class="registered-links-header">
|
<table class="registered-pages-table">
|
||||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
<colgroup>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
<col class="col-page-title" style="width: 25%;">
|
||||||
Registered Pages
|
<col class="col-page-paths" style="width: 25%;">
|
||||||
</h3>
|
<col class="col-owner" style="width: 15%;">
|
||||||
</div>
|
<col class="col-page-status" style="width: 10%;">
|
||||||
|
<col class="col-page-qr" style="width: 8%;">
|
||||||
<div class="table-container">
|
<col class="col-page-actions" style="width: 17%;">
|
||||||
<table class="registered-pages-table">
|
</colgroup>
|
||||||
<colgroup>
|
<thead>
|
||||||
<col class="col-page-title">
|
<tr>
|
||||||
<col class="col-page-paths">
|
<th>Page</th>
|
||||||
<col class="col-page-status">
|
<th>Paths</th>
|
||||||
<col class="col-page-qr">
|
<th>Owner / Tenant</th>
|
||||||
<col class="col-page-actions">
|
<th>Status</th>
|
||||||
</colgroup>
|
<th>QR Code</th>
|
||||||
<thead>
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if pages.is_empty() %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>Page</th>
|
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||||
<th>Paths</th>
|
No landing pages registered across tenants.
|
||||||
<th>Status</th>
|
</td>
|
||||||
<th>QR Code</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
{% else %}
|
||||||
<tbody>
|
{% for row in pages %}
|
||||||
{% if pages.is_empty() %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
<td data-label="Page">
|
||||||
No landing pages registered. Create one to get started!
|
<div class="page-title-block">
|
||||||
|
<div class="page-title-heading">{{ row.page.title }}</div>
|
||||||
|
<div class="page-title-meta">UUID: {{ row.page.id[0..8] }}...</div>
|
||||||
|
<div class="page-title-meta">Created {{ row.page.created_at[0..10] }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Paths">
|
||||||
|
<div class="page-paths">
|
||||||
|
<div class="page-path-group">
|
||||||
|
<span class="page-path-caption">Short Path</span>
|
||||||
|
<a href="/p/{{ row.page.code }}" target="_blank" class="page-path-link">
|
||||||
|
/p/{{ row.page.code }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="page-path-group">
|
||||||
|
<span class="page-path-caption">SEO Preview Path</span>
|
||||||
|
<a href="/p/{{ row.page.code }}/{{ row.page.slug }}" target="_blank" class="page-path-link secondary">
|
||||||
|
/p/{{ row.page.code }}/{{ row.page.slug }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Owner / Tenant">
|
||||||
|
<div>
|
||||||
|
{% if let Some(u) = row.owner_username.as_deref() %}
|
||||||
|
<strong style="color: var(--text-primary);">{{ u }}</strong>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Unassigned</span>
|
||||||
|
{% endif %}
|
||||||
|
<div style="font-family: monospace; font-size: 0.72rem; color: var(--text-muted);">{{ row.owner_tenant_id }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Status" class="status-cell">
|
||||||
|
<span class="badge badge-{{ row.page.state }}">
|
||||||
|
{{ row.page.state }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{% let code = row.page.code.as_str() %}
|
||||||
|
{% include "components/qr_preview.html" %}
|
||||||
|
<td data-label="Actions" class="actions-cell">
|
||||||
|
<div class="action-stack" style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
|
||||||
|
<a href="/admin/moderation" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
|
||||||
|
🛡️ Moderate
|
||||||
|
</a>
|
||||||
|
<a href="/admin/slugs" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
|
||||||
|
🔄 Transfer
|
||||||
|
</a>
|
||||||
|
<a href="/admin/analytics/page/{{ row.page.id }}" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
|
||||||
|
📊 Analytics
|
||||||
|
</a>
|
||||||
|
<form action="/admin/pages/delete/{{ row.page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');" style="display: inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="btn btn-danger" style="padding: 0.3rem 0.5rem; font-size: 0.75rem;">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
{% for page in pages %}
|
|
||||||
<tr>
|
|
||||||
<td data-label="Page">
|
|
||||||
<div class="page-title-block">
|
|
||||||
<div class="page-title-heading">{{ page.title }}</div>
|
|
||||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
|
||||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td data-label="Paths">
|
|
||||||
<div class="page-paths">
|
|
||||||
<div class="page-path-group">
|
|
||||||
<span class="page-path-caption">Short Path</span>
|
|
||||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
|
||||||
/p/{{ page.code }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="page-path-group">
|
|
||||||
<span class="page-path-caption">SEO Preview Path</span>
|
|
||||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
|
||||||
/p/{{ page.code }}/{{ page.slug }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td data-label="Status" class="status-cell">
|
|
||||||
<span class="badge badge-{{ page.state }}">
|
|
||||||
{{ page.state }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{% let code = page.code.as_str() %}
|
|
||||||
{% include "components/qr_preview.html" %}
|
|
||||||
<td data-label="Actions" class="actions-cell">
|
|
||||||
<div class="action-stack">
|
|
||||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
|
||||||
📊 Analytics
|
|
||||||
</a>
|
|
||||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pagination">
|
|
||||||
{% if current_page > 1 %}
|
|
||||||
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
|
||||||
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for p in visible_pages %}
|
|
||||||
{% if self.is_current(p) %}
|
|
||||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
|
||||||
{% else %}
|
|
||||||
<a href="/admin/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if current_page < total_pages %}
|
<div class="pagination">
|
||||||
<a href="/admin/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
{% if current_page > 1 %}
|
||||||
<a href="/admin/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||||
|
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||||
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in visible_pages %}
|
||||||
|
{% if self.is_current(p) %}
|
||||||
|
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
<a href="/admin/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="/admin/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||||
|
<a href="/admin/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||||
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+154
-243
@@ -1,18 +1,10 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block title %}Manage Short URLs - BZOD{% endblock %}
|
{% block title %}Global URL Registry - BZOD Admin{% endblock %}
|
||||||
|
|
||||||
{% block active_urls %}active{% endblock %}
|
{% block active_urls %}active{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block header_title %}Global URL Registry{% endblock %}
|
||||||
@media (max-width: 720px) {
|
|
||||||
#utm_fields > div {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block header_title %}Short URL Registry{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if let Some(err) = error %}
|
{% if let Some(err) = error %}
|
||||||
@@ -21,258 +13,177 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="urls-dashboard">
|
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||||
<div class="card url-form-card">
|
<div class="registered-links-header">
|
||||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||||
Shorten a New URL
|
Global URL Registry
|
||||||
</h3>
|
</h3>
|
||||||
|
{% if let Some(tag) = tag_filter %}
|
||||||
<form action="/admin/urls/create" method="POST">
|
<span class="badge badge-healthy" style="text-transform: none;">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
Filtered by tag: {{ tag }}
|
||||||
|
<a href="/admin/urls" style="color: inherit; text-decoration: none; margin-left: 0.5rem; font-weight: bold;">×</a>
|
||||||
<div class="form-group">
|
</span>
|
||||||
<label for="destination">Destination URL *</label>
|
{% endif %}
|
||||||
<input type="url" id="destination" name="destination" class="form-input" placeholder="https://example.com/very-long-path" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="code">Short Code (6-Hex, optional)</label>
|
|
||||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. 4f8c1a (auto-generated if empty)" pattern="[0-9a-fA-F]{6}" title="Must be exactly 6 hex characters (0-9, a-f)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="custom_slug">Custom Slug (optional)</label>
|
|
||||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !home (! followed by a-z, 0-9, -, _)" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="title">Title (optional)</label>
|
|
||||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. My Blog Post">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="description">Description (optional)</label>
|
|
||||||
<textarea id="description" name="description" class="form-input" placeholder="Notes or summary..." rows="2"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="tags">Tags (comma-separated, optional)</label>
|
|
||||||
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="expires_at">Expiration Date & Time (optional)</label>
|
|
||||||
<input type="datetime-local" id="expires_at" name="expires_at" class="form-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password Protection (optional)</label>
|
|
||||||
<input type="password" id="password" name="password" class="form-input" placeholder="Leave blank for public access">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="max_access_count">Access Limit / One-Time (optional)</label>
|
|
||||||
<input type="number" id="max_access_count" name="max_access_count" class="form-input" placeholder="e.g. 10 (auto-expires after N clicks)" min="1">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" style="border-top: 1px solid var(--border-color); padding-top: 0.75rem; margin-top: 0.75rem;">
|
|
||||||
<label style="font-weight: 600; font-size: 0.85rem; display: flex; align-items: center; gap: 0.25rem; cursor: pointer;">
|
|
||||||
<input type="checkbox" id="enable_utm" onchange="document.getElementById('utm_fields').style.display = this.checked ? 'flex' : 'none';" style="margin-right: 0.25rem;">
|
|
||||||
Add Campaign Tracking (UTM)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="utm_fields" style="display: none; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem;">
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="utm_source" style="font-size: 0.75rem;">UTM Source</label>
|
|
||||||
<input type="text" id="utm_source" name="utm_source" placeholder="e.g. bzod" class="form-input" style="padding: 0.35rem 0.5rem;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="utm_medium" style="font-size: 0.75rem;">UTM Medium</label>
|
|
||||||
<input type="text" id="utm_medium" name="utm_medium" placeholder="e.g. shortlink" class="form-input" style="padding: 0.35rem 0.5rem;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="utm_campaign" style="font-size: 0.75rem;">UTM Campaign</label>
|
|
||||||
<input type="text" id="utm_campaign" name="utm_campaign" placeholder="e.g. office" class="form-input" style="padding: 0.35rem 0.5rem;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
<div class="table-container">
|
||||||
<div class="registered-links-header">
|
<table class="registered-links-table">
|
||||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
<colgroup>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
<col class="col-short" style="width: 22%;">
|
||||||
Registered Links
|
<col class="col-destination" style="width: 26%;">
|
||||||
</h3>
|
<col class="col-owner" style="width: 14%;">
|
||||||
{% if let Some(tag) = tag_filter %}
|
<col class="col-health" style="width: 10%;">
|
||||||
<span class="badge badge-healthy" style="text-transform: none;">
|
<col class="col-qr" style="width: 8%;">
|
||||||
Filtered by tag: {{ tag }}
|
<col class="col-actions" style="width: 20%;">
|
||||||
<a href="/admin/urls" style="color: inherit; text-decoration: none; margin-left: 0.5rem; font-weight: bold;">×</a>
|
</colgroup>
|
||||||
</span>
|
<thead>
|
||||||
{% endif %}
|
<tr>
|
||||||
</div>
|
<th>Short Link</th>
|
||||||
|
<th>Destination</th>
|
||||||
<div class="table-container">
|
<th>Owner / Tenant</th>
|
||||||
<table class="registered-links-table">
|
<th>Status / Health</th>
|
||||||
<colgroup>
|
<th>QR Code</th>
|
||||||
<col class="col-short">
|
<th>Actions</th>
|
||||||
<col class="col-destination">
|
</tr>
|
||||||
<col class="col-qr">
|
</thead>
|
||||||
<col class="col-health">
|
<tbody>
|
||||||
<col class="col-tags">
|
{% if urls.is_empty() %}
|
||||||
<col class="col-actions">
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>Short Link</th>
|
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||||
<th>Destination</th>
|
No shortened URLs registered across tenants.
|
||||||
<th>QR Code</th>
|
</td>
|
||||||
<th>Health</th>
|
|
||||||
<th>Tags</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
{% else %}
|
||||||
<tbody>
|
{% for row in urls %}
|
||||||
{% if urls.is_empty() %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
<td data-label="Short Link">
|
||||||
No shortened URLs registered. Create one to get started!
|
<div class="short-link-wrap">
|
||||||
</td>
|
<div class="short-link-primary">
|
||||||
</tr>
|
<a href="/{{ row.url.code }}" target="_blank" class="short-link-anchor">
|
||||||
{% else %}
|
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ row.url.code }}
|
||||||
{% for url in urls %}
|
|
||||||
<tr>
|
|
||||||
<td data-label="Short Link">
|
|
||||||
<div class="short-link-wrap">
|
|
||||||
<div class="short-link-primary">
|
|
||||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
|
||||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
|
||||||
</a>
|
|
||||||
{% if url.is_password_protected() %}
|
|
||||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if let Some(title) = url.title.as_deref() %}
|
|
||||||
{% if !title.is_empty() %}
|
|
||||||
<div class="short-link-title">{{ title }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td data-label="Destination">
|
|
||||||
<div class="destination-block">
|
|
||||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
|
||||||
{{ url.destination }}
|
|
||||||
</a>
|
</a>
|
||||||
{% if let Some(desc) = url.description.as_deref() %}
|
{% if row.url.is_password_protected() %}
|
||||||
{% if !desc.is_empty() %}
|
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||||
{% endif %}
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
{% if let Some(title) = row.url.title.as_deref() %}
|
||||||
{% if !expires_at.is_empty() %}
|
{% if !title.is_empty() %}
|
||||||
<div class="destination-meta expiry">
|
<div class="short-link-title">{{ title }}</div>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
|
||||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
|
||||||
{% if url.expired %}
|
|
||||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="short-link-meta">Created {{ row.url.created_at[0..10] }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Destination">
|
||||||
|
<div class="destination-block">
|
||||||
|
<a href="{{ row.url.destination }}" target="_blank" class="destination-link" title="{{ row.url.destination }}" aria-label="Destination URL: {{ row.url.destination }}">
|
||||||
|
{{ row.url.destination }}
|
||||||
|
</a>
|
||||||
|
{% if let Some(desc) = row.url.description.as_deref() %}
|
||||||
|
{% if !desc.is_empty() %}
|
||||||
|
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if let Some(max) = url.max_access_count %}
|
{% if let Some(expires_at) = row.url.expires_at.as_deref() %}
|
||||||
<div class="destination-meta access">
|
{% if !expires_at.is_empty() %}
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
<div class="destination-meta expiry">
|
||||||
Clicks: {{ url.access_count }} / {{ max }}
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||||
{% if url.is_access_exhausted() %}
|
Expires: {{ expires_at[0..10] }}
|
||||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
{% if row.url.expired %}
|
||||||
|
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
{% if url.access_count > 0 %}
|
|
||||||
<div class="destination-meta neutral">
|
|
||||||
Clicks: {{ url.access_count }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{% let code = url.code.as_str() %}
|
|
||||||
{% include "components/qr_preview.html" %}
|
|
||||||
<td data-label="Health" class="status-cell">
|
|
||||||
<span class="badge badge-{{ url.status }}">
|
|
||||||
{{ url.status }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td data-label="Tags">
|
|
||||||
{% if url.tags.is_empty() %}
|
|
||||||
<span class="tags-empty">—</span>
|
|
||||||
{% else %}
|
|
||||||
<div class="tags-wrap">
|
|
||||||
{% for t in url.tags %}
|
|
||||||
<a href="/admin/urls?tag={{ t }}" class="badge tag-badge">
|
|
||||||
{{ t }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
|
||||||
<td data-label="Actions" class="actions-cell">
|
|
||||||
<div class="action-stack">
|
|
||||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
|
||||||
📊 Analytics
|
|
||||||
</a>
|
|
||||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pagination">
|
|
||||||
{% if current_page > 1 %}
|
|
||||||
<a href="/admin/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
|
||||||
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for p in visible_pages %}
|
{% if let Some(max) = row.url.max_access_count %}
|
||||||
{% if self.is_current(p) %}
|
<div class="destination-meta access">
|
||||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||||
{% else %}
|
Clicks: {{ row.url.access_count }} / {{ max }}
|
||||||
<a href="/admin/urls?page={{ p }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
{% if row.url.is_access_exhausted() %}
|
||||||
|
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if row.url.access_count > 0 %}
|
||||||
|
<div class="destination-meta neutral">
|
||||||
|
Clicks: {{ row.url.access_count }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Owner / Tenant">
|
||||||
|
<div>
|
||||||
|
{% if let Some(u) = row.owner_username.as_deref() %}
|
||||||
|
<strong style="color: var(--text-primary);">{{ u }}</strong>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Unassigned</span>
|
||||||
|
{% endif %}
|
||||||
|
<div style="font-family: monospace; font-size: 0.72rem; color: var(--text-muted);">{{ row.owner_tenant_id }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Status / Health" class="status-cell">
|
||||||
|
<span class="badge badge-{{ row.url.status }}">
|
||||||
|
{{ row.url.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{% let code = row.url.code.as_str() %}
|
||||||
|
{% include "components/qr_preview.html" %}
|
||||||
|
<td data-label="Actions" class="actions-cell">
|
||||||
|
<div class="action-stack" style="display: flex; gap: 0.35rem; flex-wrap: wrap;">
|
||||||
|
<a href="/admin/moderation" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
|
||||||
|
🛡️ Moderate
|
||||||
|
</a>
|
||||||
|
<a href="/admin/slugs" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
|
||||||
|
🔄 Transfer
|
||||||
|
</a>
|
||||||
|
<a href="/admin/analytics/url/{{ row.url.id }}" class="btn btn-secondary" style="padding: 0.3rem 0.5rem; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.2rem;">
|
||||||
|
📊 Analytics
|
||||||
|
</a>
|
||||||
|
<form action="/admin/urls/delete/{{ row.url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to retire this link?');" style="display: inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="btn btn-danger" style="padding: 0.3rem 0.5rem; font-size: 0.75rem;">
|
||||||
|
Retire
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if current_page < total_pages %}
|
<div class="pagination">
|
||||||
<a href="/admin/urls?page={{ current_page + 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
{% if current_page > 1 %}
|
||||||
<a href="/admin/urls?page={{ total_pages }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
<a href="/admin/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||||
|
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||||
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in visible_pages %}
|
||||||
|
{% if self.is_current(p) %}
|
||||||
|
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
<a href="/admin/urls?page={{ p }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="/admin/urls?page={{ current_page + 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||||
|
<a href="/admin/urls?page={{ total_pages }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||||
|
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -202,12 +202,10 @@ async fn test_full_http_e2e_flow() {
|
|||||||
let res = client.get(&user_dashboard_url).send().await.unwrap();
|
let res = client.get(&user_dashboard_url).send().await.unwrap();
|
||||||
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||||
|
|
||||||
// Try to access admin dashboard as standard user (RBAC check - should redirect to admin login)
|
// Try to access admin dashboard as standard user (RBAC check - should return 403 Forbidden)
|
||||||
let admin_dashboard_url = format!("{}/admin/dashboard", base_url);
|
let admin_dashboard_url = format!("{}/admin/dashboard", base_url);
|
||||||
let res = client.get(&admin_dashboard_url).send().await.unwrap();
|
let res = client.get(&admin_dashboard_url).send().await.unwrap();
|
||||||
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
|
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
|
|
||||||
assert_eq!(redirect_url, "/admin/login");
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
//! v0.8.0 Phase 5 Correction: Admin Core-Only Capability Boundary Tests
|
||||||
|
//!
|
||||||
|
//! Required Test Cases:
|
||||||
|
//! 1. Admin cannot create URL through POST /admin/urls/create (403 Forbidden).
|
||||||
|
//! 2. Admin cannot create landing page through POST /admin/pages/create (403 Forbidden).
|
||||||
|
//! 3. Admin cannot create URL through API (403 Forbidden).
|
||||||
|
//! 4. Admin cannot create landing page through API (403 Forbidden).
|
||||||
|
//! 5. Admin cannot bulk-create URLs through API (403 Forbidden).
|
||||||
|
//! 6. Admin GET /admin/urls renders inspection-only registry (no creation form).
|
||||||
|
//! 7. Admin GET /admin/pages renders inspection-only registry (no creation form).
|
||||||
|
//! 8. Admin resource inspection still works on global URL registry.
|
||||||
|
//! 9. Admin resource inspection still works on global page registry.
|
||||||
|
//! 10. Admin moderation still works.
|
||||||
|
//! 11. Admin transfer still works.
|
||||||
|
//! 12. Normal tenant can still create URLs (via UI and API).
|
||||||
|
//! 13. Normal tenant can still create landing pages (via UI and API).
|
||||||
|
//! 14. Normal tenant creation uses its own TenantId.
|
||||||
|
//! 15. No test or creation path uses users/1 as an application tenant.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use bzod::analytics::AnalyticsQueue;
|
||||||
|
use bzod::config::Config;
|
||||||
|
use bzod::db::topology::Topology;
|
||||||
|
use bzod::db::Db;
|
||||||
|
use bzod::state::AppState;
|
||||||
|
use bzod::web::create_router;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct TestHarness {
|
||||||
|
temp_dir: PathBuf,
|
||||||
|
config: Config,
|
||||||
|
db: Db,
|
||||||
|
base_url: String,
|
||||||
|
admin_client: reqwest::Client,
|
||||||
|
user_client: reqwest::Client,
|
||||||
|
admin_api_key: String,
|
||||||
|
bob_user_id: i64,
|
||||||
|
bob_tenant_id: bzod::identity::TenantId,
|
||||||
|
bob_token_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestHarness {
|
||||||
|
async fn setup() -> Self {
|
||||||
|
let temp_dir =
|
||||||
|
std::env::temp_dir().join(format!("bzod_admin_boundary_{}", uuid::Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
let mut config = Config::load();
|
||||||
|
config.data_dir = temp_dir.clone();
|
||||||
|
config.backup_dir = temp_dir.join("backups");
|
||||||
|
config.base_url = Some("http://localhost:8080".to_string());
|
||||||
|
|
||||||
|
let db = Db::init(&config).expect("Db::init failed");
|
||||||
|
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, tokio::sync::watch::channel(false).1);
|
||||||
|
let state = AppState {
|
||||||
|
admin_db: db.admin.clone(),
|
||||||
|
system_db: db.system.clone(),
|
||||||
|
users_db: db.users.clone(),
|
||||||
|
user_dbs: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
db: db.clone(),
|
||||||
|
config: config.clone(),
|
||||||
|
analytics_queue: queue,
|
||||||
|
start_time: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = create_router(state.clone());
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let base_url = format!("http://{}", addr);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let admin_client = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let user_client = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Create Admin
|
||||||
|
let _ = bzod::cli::create_admin::run(
|
||||||
|
Some("core_admin".to_string()),
|
||||||
|
Some("AdminPass123!".to_string()),
|
||||||
|
None,
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let admin_user = {
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
bzod::db::users::get_user_by_username(&conn, "core_admin")
|
||||||
|
.unwrap()
|
||||||
|
.expect("Admin must exist")
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Create Normal Tenant
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("tenant_bob".to_string()),
|
||||||
|
Some("UserPass123!".to_string()),
|
||||||
|
None,
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (bob_user_id, bob_tenant_id) = {
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
let u = bzod::db::users::get_user_by_username(&conn, "tenant_bob")
|
||||||
|
.unwrap()
|
||||||
|
.expect("Bob must exist");
|
||||||
|
(u.id, u.tenant_id.expect("Bob must have a TenantId"))
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Admin login
|
||||||
|
let admin_csrf = extract_csrf(&admin_client, &format!("{}/admin/login", base_url)).await;
|
||||||
|
let mut admin_login_params = HashMap::new();
|
||||||
|
admin_login_params.insert("username", "core_admin");
|
||||||
|
admin_login_params.insert("password", "AdminPass123!");
|
||||||
|
admin_login_params.insert("csrf_token", admin_csrf.as_str());
|
||||||
|
|
||||||
|
let admin_login_res = admin_client
|
||||||
|
.post(format!("{}/admin/login", base_url))
|
||||||
|
.form(&admin_login_params)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(admin_login_res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
// 4. User login
|
||||||
|
let user_csrf = extract_csrf(&user_client, &format!("{}/login", base_url)).await;
|
||||||
|
let mut user_login_params = HashMap::new();
|
||||||
|
user_login_params.insert("username", "tenant_bob");
|
||||||
|
user_login_params.insert("password", "UserPass123!");
|
||||||
|
user_login_params.insert("csrf_token", user_csrf.as_str());
|
||||||
|
|
||||||
|
let user_login_res = user_client
|
||||||
|
.post(format!("{}/login", base_url))
|
||||||
|
.form(&user_login_params)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(user_login_res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
// 5. Create API tokens
|
||||||
|
let admin_key_secret = format!("bzo_{}", bzod::utils::generate_token(16));
|
||||||
|
{
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(admin_key_secret.as_bytes());
|
||||||
|
let hashed_key = hex::encode(hasher.finalize());
|
||||||
|
let conn = db.admin.lock().unwrap();
|
||||||
|
let _ = bzod::db::admin::create_api_key(
|
||||||
|
&conn,
|
||||||
|
&admin_user.id.to_string(),
|
||||||
|
"Admin Test Key",
|
||||||
|
&hashed_key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let bob_token_secret = format!("bzou_{}", bzod::utils::generate_token(16));
|
||||||
|
{
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(bob_token_secret.as_bytes());
|
||||||
|
let hashed_token = hex::encode(hasher.finalize());
|
||||||
|
let conn = db.users.lock().unwrap();
|
||||||
|
let _ =
|
||||||
|
bzod::db::users::create_user_api_token(&conn, bob_user_id, &hashed_token).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
TestHarness {
|
||||||
|
temp_dir,
|
||||||
|
config,
|
||||||
|
db,
|
||||||
|
base_url,
|
||||||
|
admin_client,
|
||||||
|
user_client,
|
||||||
|
admin_api_key: admin_key_secret,
|
||||||
|
bob_user_id,
|
||||||
|
bob_tenant_id,
|
||||||
|
bob_token_secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestHarness {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.temp_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn extract_csrf(client: &reqwest::Client, url: &str) -> String {
|
||||||
|
let html = client.get(url).send().await.unwrap().text().await.unwrap();
|
||||||
|
html.split("name=\"csrf_token\" value=\"")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split('"').next())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_01_admin_cannot_create_url_post() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let csrf = extract_csrf(&h.admin_client, &format!("{}/admin/urls", h.base_url)).await;
|
||||||
|
let mut form = HashMap::new();
|
||||||
|
form.insert("destination", "https://fail.com");
|
||||||
|
form.insert("code", "!fail");
|
||||||
|
form.insert("csrf_token", csrf.as_str());
|
||||||
|
|
||||||
|
let res = h
|
||||||
|
.admin_client
|
||||||
|
.post(format!("{}/admin/urls/create", h.base_url))
|
||||||
|
.form(&form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"Admin POST /admin/urls/create must return 403"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_02_admin_cannot_create_landing_page_post() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let csrf = extract_csrf(&h.admin_client, &format!("{}/admin/pages", h.base_url)).await;
|
||||||
|
let mut form = HashMap::new();
|
||||||
|
form.insert("title", "Admin Page");
|
||||||
|
form.insert("slug", "admin-page");
|
||||||
|
form.insert("code", "a1b2");
|
||||||
|
form.insert("state", "published");
|
||||||
|
form.insert("html_content", "<h1>Admin</h1>");
|
||||||
|
form.insert("csrf_token", csrf.as_str());
|
||||||
|
|
||||||
|
let res = h
|
||||||
|
.admin_client
|
||||||
|
.post(format!("{}/admin/pages/create", h.base_url))
|
||||||
|
.form(&form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"Admin POST /admin/pages/create must return 403"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_03_admin_cannot_create_url_api() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let api_client = reqwest::Client::new();
|
||||||
|
let res = api_client
|
||||||
|
.post(format!("{}/api/v1/urls", h.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", h.admin_api_key))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"destination": "https://admin-api-fail.com",
|
||||||
|
"code": "!admin_api"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"Admin API POST /api/v1/urls must return 403"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_04_admin_cannot_create_landing_page_api() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let api_client = reqwest::Client::new();
|
||||||
|
let res = api_client
|
||||||
|
.post(format!("{}/api/v1/pages", h.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", h.admin_api_key))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"title": "Admin Page API",
|
||||||
|
"slug": "admin-page-api",
|
||||||
|
"html_content": "<h1>Admin API</h1>",
|
||||||
|
"state": "published"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"Admin API POST /api/v1/pages must return 403"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_05_admin_cannot_bulk_create_urls_api() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let api_client = reqwest::Client::new();
|
||||||
|
let res = api_client
|
||||||
|
.post(format!("{}/api/v1/bulk/url", h.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", h.admin_api_key))
|
||||||
|
.json(&serde_json::json!([
|
||||||
|
{ "destination": "https://bulk1.com", "code": "!b1" },
|
||||||
|
{ "destination": "https://bulk2.com", "code": "!b2" }
|
||||||
|
]))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"Admin API POST /api/v1/bulk/url must return 403"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_06_admin_urls_registry_renders_no_creation_form() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let html = h
|
||||||
|
.admin_client
|
||||||
|
.get(format!("{}/admin/urls", h.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!html.contains("Shorten a New URL"),
|
||||||
|
"Admin /admin/urls must NOT contain 'Shorten a New URL'"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!html.contains("action=\"/admin/urls/create\""),
|
||||||
|
"Admin /admin/urls must NOT contain creation form action"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("Global URL Registry"),
|
||||||
|
"Admin /admin/urls must render Global URL Registry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_07_admin_pages_registry_renders_no_creation_form() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let html = h
|
||||||
|
.admin_client
|
||||||
|
.get(format!("{}/admin/pages", h.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!html.contains("Create a New Landing Page"),
|
||||||
|
"Admin /admin/pages must NOT contain 'Create a New Landing Page'"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!html.contains("action=\"/admin/pages/create\""),
|
||||||
|
"Admin /admin/pages must NOT contain creation form action"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("Global Landing Page Registry"),
|
||||||
|
"Admin /admin/pages must render Global Landing Page Registry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_08_and_09_admin_inspection_of_global_registries() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let api_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Bob creates URL
|
||||||
|
let res = api_client
|
||||||
|
.post(format!("{}/api/v1/urls", h.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", h.bob_token_secret))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"destination": "https://bob-portfolio.org",
|
||||||
|
"code": "b0b001"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Bob creates Landing Page via UI
|
||||||
|
let bob_pages_csrf = extract_csrf(&h.user_client, &format!("{}/user/pages", h.base_url)).await;
|
||||||
|
let mut bob_page_form = HashMap::new();
|
||||||
|
bob_page_form.insert("title", "Bob Landing Page");
|
||||||
|
bob_page_form.insert("slug", "bob-page");
|
||||||
|
bob_page_form.insert("code", "a1b2");
|
||||||
|
bob_page_form.insert("custom_slug", "");
|
||||||
|
bob_page_form.insert("state", "published");
|
||||||
|
bob_page_form.insert("html_content", "<h1>Welcome to Bob's Page</h1>");
|
||||||
|
bob_page_form.insert("csrf_token", bob_pages_csrf.as_str());
|
||||||
|
|
||||||
|
let res = h
|
||||||
|
.user_client
|
||||||
|
.post(format!("{}/user/pages/create", h.base_url))
|
||||||
|
.form(&bob_page_form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
// Test 8: Admin can inspect URL registry
|
||||||
|
let admin_urls_inspected = h
|
||||||
|
.admin_client
|
||||||
|
.get(format!("{}/admin/urls", h.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
admin_urls_inspected.contains("b0b001"),
|
||||||
|
"Admin URL registry must display tenant URLs"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
admin_urls_inspected.contains("tenant_bob"),
|
||||||
|
"Admin URL registry must display owner username"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
admin_urls_inspected.contains(h.bob_tenant_id.as_str()),
|
||||||
|
"Admin URL registry must display owner TenantId"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 9: Admin can inspect Landing Page registry
|
||||||
|
let admin_pages_inspected = h
|
||||||
|
.admin_client
|
||||||
|
.get(format!("{}/admin/pages", h.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
admin_pages_inspected.contains("Bob Landing Page"),
|
||||||
|
"Admin Page registry must display tenant landing pages"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
admin_pages_inspected.contains("tenant_bob"),
|
||||||
|
"Admin Page registry must display owner username"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
admin_pages_inspected.contains(h.bob_tenant_id.as_str()),
|
||||||
|
"Admin Page registry must display owner TenantId"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_10_admin_moderation_functional() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let api_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let _ = api_client
|
||||||
|
.post(format!("{}/api/v1/urls", h.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", h.bob_token_secret))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"destination": "https://spam.org",
|
||||||
|
"code": "!spam-link"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let urls_conn = h.db.global_urls.lock().unwrap();
|
||||||
|
let pages_conn = h.db.global_landing_pages.lock().unwrap();
|
||||||
|
let retired = bzod::db::slugs::retire_slug(&urls_conn, &pages_conn, "!spam-link").unwrap();
|
||||||
|
assert!(retired, "Admin moderation (retire slug) must succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_11_admin_transfer_functional() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
let api_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let _ = api_client
|
||||||
|
.post(format!("{}/api/v1/urls", h.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", h.bob_token_secret))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"destination": "https://transfer-target.org",
|
||||||
|
"code": "!transfer-link"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let _ = bzod::cli::create_user::run(
|
||||||
|
Some("tenant_alice".to_string()),
|
||||||
|
Some("AlicePass123!".to_string()),
|
||||||
|
None,
|
||||||
|
h.config.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let alice_tenant_id = {
|
||||||
|
let conn = h.db.users.lock().unwrap();
|
||||||
|
let u = bzod::db::users::get_user_by_username(&conn, "tenant_alice")
|
||||||
|
.unwrap()
|
||||||
|
.expect("Alice must exist");
|
||||||
|
u.tenant_id.expect("Alice must have a TenantId")
|
||||||
|
};
|
||||||
|
|
||||||
|
let urls_conn = h.db.global_urls.lock().unwrap();
|
||||||
|
let pages_conn = h.db.global_landing_pages.lock().unwrap();
|
||||||
|
let transferred = bzod::db::slugs::transfer_slug_owner(
|
||||||
|
&urls_conn,
|
||||||
|
&pages_conn,
|
||||||
|
"!transfer-link",
|
||||||
|
&alice_tenant_id,
|
||||||
|
"new_target_id",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(transferred, "Admin slug transfer to Alice must succeed");
|
||||||
|
|
||||||
|
let lookup = bzod::db::slugs::lookup_slug(&urls_conn, &pages_conn, "!transfer-link")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lookup.owner_tenant_id,
|
||||||
|
alice_tenant_id.as_str(),
|
||||||
|
"Slug owner must now be Alice's TenantId"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_12_13_14_15_normal_tenant_creation_and_topology() {
|
||||||
|
let h = TestHarness::setup().await;
|
||||||
|
|
||||||
|
// Verify User UI contains creation form
|
||||||
|
let user_urls_html = h
|
||||||
|
.user_client
|
||||||
|
.get(format!("{}/user/urls", h.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
user_urls_html.contains("Create a New URL"),
|
||||||
|
"User /user/urls MUST contain creation form"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user_urls_html.contains("action=\"/user/urls/create\""),
|
||||||
|
"User /user/urls MUST post to /user/urls/create"
|
||||||
|
);
|
||||||
|
|
||||||
|
// UI URL creation
|
||||||
|
let bob_urls_csrf = extract_csrf(&h.user_client, &format!("{}/user/urls", h.base_url)).await;
|
||||||
|
let mut bob_url_form = HashMap::new();
|
||||||
|
bob_url_form.insert("destination", "https://bob-portfolio.org");
|
||||||
|
bob_url_form.insert("code", "b0b001");
|
||||||
|
bob_url_form.insert("title", "Bob's Portfolio");
|
||||||
|
bob_url_form.insert("csrf_token", bob_urls_csrf.as_str());
|
||||||
|
|
||||||
|
let res = h
|
||||||
|
.user_client
|
||||||
|
.post(format!("{}/user/urls/create", h.base_url))
|
||||||
|
.form(&bob_url_form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
reqwest::StatusCode::SEE_OTHER,
|
||||||
|
"User URL creation must succeed and redirect"
|
||||||
|
);
|
||||||
|
|
||||||
|
// UI Landing page creation
|
||||||
|
let bob_pages_csrf = extract_csrf(&h.user_client, &format!("{}/user/pages", h.base_url)).await;
|
||||||
|
let mut bob_page_form = HashMap::new();
|
||||||
|
bob_page_form.insert("title", "Bob Landing Page");
|
||||||
|
bob_page_form.insert("slug", "bob-page");
|
||||||
|
bob_page_form.insert("code", "a1b2");
|
||||||
|
bob_page_form.insert("custom_slug", "");
|
||||||
|
bob_page_form.insert("state", "published");
|
||||||
|
bob_page_form.insert("html_content", "<h1>Welcome to Bob's Page</h1>");
|
||||||
|
bob_page_form.insert("csrf_token", bob_pages_csrf.as_str());
|
||||||
|
|
||||||
|
let res = h
|
||||||
|
.user_client
|
||||||
|
.post(format!("{}/user/pages/create", h.base_url))
|
||||||
|
.form(&bob_page_form)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res.status(),
|
||||||
|
reqwest::StatusCode::SEE_OTHER,
|
||||||
|
"User landing page creation must succeed and redirect"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 14: Verify content is stored under users/<TenantId>/content.db
|
||||||
|
let topology = Topology::new(&h.temp_dir);
|
||||||
|
let bob_tenant_dir = topology.user_dir(h.bob_tenant_id.as_str()).unwrap();
|
||||||
|
assert!(
|
||||||
|
bob_tenant_dir.join("content.db").exists(),
|
||||||
|
"Bob's content.db must exist"
|
||||||
|
);
|
||||||
|
|
||||||
|
let bob_conn = rusqlite::Connection::open(bob_tenant_dir.join("content.db")).unwrap();
|
||||||
|
let url_count: i64 = bob_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
url_count, 1,
|
||||||
|
"Bob's content.db must hold exactly 1 URL created by Bob"
|
||||||
|
);
|
||||||
|
|
||||||
|
let page_count: i64 = bob_conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page_count, 1,
|
||||||
|
"Bob's content.db must hold exactly 1 landing page created by Bob"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 15: No creation path uses users/1 or integer paths
|
||||||
|
let legacy_int_dir = h.temp_dir.join("users").join("1");
|
||||||
|
assert!(!legacy_int_dir.exists(), "users/1 must NOT exist anywhere");
|
||||||
|
}
|
||||||
@@ -158,19 +158,9 @@ async fn test_admin_cannot_create_unowned_application_resources() {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(create_url_res.status(), reqwest::StatusCode::SEE_OTHER);
|
assert_eq!(create_url_res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
let location = create_url_res
|
|
||||||
.headers()
|
|
||||||
.get("location")
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
location.contains("error="),
|
|
||||||
"Admin URL creation must be rejected with error redirect"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Admin attempts to create Landing Page via POST /admin/pages/create -> Should redirect with error
|
// 4. Admin attempts to create Landing Page via POST /admin/pages/create -> Should return FORBIDDEN
|
||||||
let mut page_form = HashMap::new();
|
let mut page_form = HashMap::new();
|
||||||
page_form.insert("title", "Admin Page");
|
page_form.insert("title", "Admin Page");
|
||||||
page_form.insert("slug", "!admin_page");
|
page_form.insert("slug", "!admin_page");
|
||||||
@@ -186,17 +176,7 @@ async fn test_admin_cannot_create_unowned_application_resources() {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(create_page_res.status(), reqwest::StatusCode::SEE_OTHER);
|
assert_eq!(create_page_res.status(), reqwest::StatusCode::FORBIDDEN);
|
||||||
let location = create_page_res
|
|
||||||
.headers()
|
|
||||||
.get("location")
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
location.contains("error="),
|
|
||||||
"Admin Landing Page creation must be rejected with error redirect"
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -297,7 +297,7 @@ footer{background:var(--bg);border-top:1px solid var(--border);padding:4rem 2rem
|
|||||||
<span class="to">Cloning into 'bzod'...</span>
|
<span class="to">Cloning into 'bzod'...</span>
|
||||||
|
|
||||||
<span class="tp">$</span> <span class="tc">cargo build --release</span>
|
<span class="tp">$</span> <span class="tc">cargo build --release</span>
|
||||||
<span class="to"> Compiling bzod v0.5.0</span>
|
<span class="to"> Compiling bzod v0.8.0</span>
|
||||||
<span class="tg"> Finished release [optimized] in 12.58s</span>
|
<span class="tg"> Finished release [optimized] in 12.58s</span>
|
||||||
|
|
||||||
<span class="tp">$</span> <span class="tc">./target/release/bzod serve</span>
|
<span class="tp">$</span> <span class="tc">./target/release/bzod serve</span>
|
||||||
@@ -537,7 +537,7 @@ Authorization: Bearer bzo_xxxxxxxxxxxx
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Feature</th>
|
<th>Feature</th>
|
||||||
<th>BZOD v0.5.0</th>
|
<th>BZOD v0.8.0</th>
|
||||||
<th>Shlink</th>
|
<th>Shlink</th>
|
||||||
<th>YOURLS</th>
|
<th>YOURLS</th>
|
||||||
<th>Chhoto URL</th>
|
<th>Chhoto URL</th>
|
||||||
|
|||||||
Reference in new issue
Block a user