release: finalize BZOD v0.8.0
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s

This commit is contained in:
thakares committed 2026-08-21 16:23:32 +05:30
1 parent d7e0ac7679
commit d398341f01
35 files changed
+2227 -335

No files matched your search

Generated
+1 -1
View File
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzod"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"argon2",
"askama",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "bzod"
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
version = "0.7.0"
version = "0.8.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/thakares/nx9-url-shortener"
+3 -3
View File
@@ -6,7 +6,7 @@
![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue)
![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey)
![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green)
![Version](https://img.shields.io/badge/Version-v0.7.0-purple)
![Version](https://img.shields.io/badge/Version-v0.8.0-purple)
[![GitHub](https://img.shields.io/badge/GitHub-thakares%2Fbzod-181717?logo=github)](https://github.com/thakares/bzod)
[![Codeberg](https://img.shields.io/badge/Codeberg-thakares%2Fbzod-2185D0?logo=codeberg)](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 |
|---------|------:|
@@ -1337,7 +1337,7 @@ Community feedback helps guide future development.
**Current Version**
**v0.7.0**
**v0.8.0**
Production Ready
+2 -2
View File
@@ -11,7 +11,7 @@
# sudo bash deploy.sh
#
# Environment overrides:
# BZOD_VERSION=0.7.1
# BZOD_VERSION=0.8.0
# BZOD_IMAGE=nx9-url-shortener
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
# BZOD_PORT=8654
@@ -23,7 +23,7 @@ set -euo pipefail
# Configuration
# ============================================================
BZOD_VERSION="${BZOD_VERSION:-0.7.1}"
BZOD_VERSION="${BZOD_VERSION:-0.8.0}"
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
BZOD_PORT="${BZOD_PORT:-8654}"
+1 -1
View File
@@ -27,7 +27,7 @@ services:
hostname: bzod
image: nx9-url-shortener:v0.7.0
image: nx9-url-shortener:v0.8.0
ports:
- mode: ingress
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Administrator Guide
Version: v0.7.0
Version: v0.8.0
---
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Architecture Guide
Version: v0.7.0
Version: v0.8.0
---
+1 -1
View File
@@ -1,6 +1,6 @@
# Backup & Restore Guide
Version: v0.7.0
Version: v0.8.0
Applies To: BZOD Multi-User Platform
---
+25
View File
@@ -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.
# 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
+1 -1
View File
@@ -2,7 +2,7 @@
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
$ bzod --help
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -2,7 +2,7 @@
# 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.
+3 -3
View File
@@ -1,6 +1,6 @@
# BZOD Installation Guide
Version: v0.7.0
Version: v0.8.0
---
@@ -183,13 +183,13 @@ sudo pacman -S \
Example:
```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:
```bash
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
tar -xzf bzod-v0.8.0-linux-amd64.tar.gz
```
Install:
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Multi-User Architecture Guide
Version: v0.7.0
Version: v0.8.0
---
+27
View File
@@ -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
Release Date: 2026-08-11
+4 -4
View File
@@ -1,6 +1,6 @@
# 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
* 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
BZOD v0.7.0 includes tests covering:
BZOD v0.8.0 includes tests covering:
* Authentication
* Authorization
@@ -665,7 +665,7 @@ These may be addressed in future releases.
# Summary
BZOD v0.7.0 provides:
BZOD v0.8.0 provides:
* Centralized authentication
* Secure session management
+14 -2
View File
@@ -1,11 +1,23 @@
# 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
BZOD v0.5.1 is a platform hardening release focused on:
+4 -4
View File
@@ -198,9 +198,9 @@ pub fn authenticate_user_session(
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)?
.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 {
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()?;
if let Some(user_id) = user_id_opt {
let user_opt =
crate::db::users::get_user_by_id(users_conn, user_id)?.filter(|u| u.status == "active");
let user_opt = crate::db::users::get_user_by_id(users_conn, user_id)?
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
if let Some(user) = user_opt {
return Ok(Some(ApiActor::User(user)));
+7 -1
View File
@@ -5,11 +5,17 @@ use axum::{
response::{Html, IntoResponse, Response},
};
pub struct AdminPageRow {
pub page: LandingPage,
pub owner_tenant_id: String,
pub owner_username: Option<String>,
}
#[derive(Template)]
#[template(path = "pages.html")]
pub struct PagesTemplate {
pub admin_username: String,
pub pages: Vec<LandingPage>,
pub pages: Vec<AdminPageRow>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
+7 -1
View File
@@ -5,11 +5,17 @@ use axum::{
response::{Html, IntoResponse, Response},
};
pub struct AdminUrlRow {
pub url: Url,
pub owner_tenant_id: String,
pub owner_username: Option<String>,
}
#[derive(Template)]
#[template(path = "urls.html")]
pub struct UrlsTemplate {
pub admin_username: String,
pub urls: Vec<Url>,
pub urls: Vec<AdminUrlRow>,
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
+86 -15
View File
@@ -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
pub async fn login_post(
State(state): State<AppState>,
@@ -233,6 +258,14 @@ pub async fn login_post(
let session_token = generate_token(32);
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() {
Ok(c) => c,
@@ -279,6 +312,7 @@ pub async fn login_post(
.build();
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()
@@ -301,14 +335,17 @@ pub async fn login_post(
}
// GET /logout
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response {
let cookie = Cookie::build("bzod_user_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
pub async fn public_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
if let Some(user_cookie) = jar.get("bzod_user_session") {
invalidate_session_in_db(&state, user_cookie.value());
}
if let Some(admin_cookie) = jar.get("bzod_session") {
invalidate_session_in_db(&state, admin_cookie.value());
}
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()
}
@@ -383,6 +420,14 @@ pub async fn public_login_post(
let session_token = generate_token(32);
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 _ =
@@ -406,6 +451,32 @@ pub async fn public_login_post(
let secure_flag =
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
if user.account_type == "admin" {
let cookie = Cookie::build(("bzod_session", session_token))
.path("/")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::days(30))
.build();
let clear_temp = Cookie::build("bzod_temp_csrf")
.path("/login")
.max_age(time::Duration::ZERO)
.build();
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)
@@ -420,10 +491,12 @@ pub async fn public_login_post(
.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 => {
let system_conn = state.system_db.lock().unwrap();
let metadata = format!(
@@ -446,18 +519,16 @@ pub async fn public_login_post(
// GET /admin/logout
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]);
if let Some(admin_cookie) = jar.get("bzod_session") {
invalidate_session_in_db(&state, admin_cookie.value());
}
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();
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()
}
+51 -9
View File
@@ -86,32 +86,74 @@ pub(crate) fn write_audit_log(
pub(crate) const PAGE_SIZE: usize = 25;
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
// Helper: Verify admin session and return user or redirect to login
// Helper: Verify admin session and return user or error response (403 Forbidden / login redirect)
pub(crate) async fn require_auth(
state: &AppState,
jar: &CookieJar,
) -> Result<(User, String), Redirect> {
) -> Result<(User, String), Response> {
let conn = match state.users_db.lock() {
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) {
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());
}
}
// Helper: Verify tenant user session and return user or redirect to login
Err(Redirect::to("/admin/login").into_response())
}
Err(_) => Err(Redirect::to("/admin/login").into_response()),
}
}
// Helper: Verify tenant user session and return tenant user or error response (403 Forbidden / login redirect)
pub(crate) async fn require_user_auth(
state: &AppState,
jar: &CookieJar,
) -> Result<(crate::models::TenantUser, String), Redirect> {
) -> Result<(crate::models::TenantUser, String), Response> {
let conn = match state.users_db.lock() {
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) {
Ok(Some((user, session_id))) => Ok((user, session_id)),
_ => Err(Redirect::to("/login")),
Ok(Some((user, session_id))) => {
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)]
+28 -8
View File
@@ -118,9 +118,10 @@ pub async fn user_pages_create(
}
}
let owner_tid = user
.tenant_id
.unwrap_or_else(crate::identity::TenantId::generate);
let owner_tid = match user.tenant_id {
Some(tid) => tid,
None => return (StatusCode::FORBIDDEN, "Missing tenant identity").into_response(),
};
{
let reserved_conn = state.db.reserved.lock().unwrap();
@@ -319,10 +320,10 @@ pub async fn pages_get(
}
}
}
if let Some(p) = resolved_page {
pages.push(p);
let page = if let Some(p) = resolved_page {
p
} else {
pages.push(crate::models::LandingPage {
crate::models::LandingPage {
id: target_id,
code: slug.clone(),
slug,
@@ -331,8 +332,23 @@ pub async fn pages_get(
state: status,
created_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);
@@ -382,7 +398,11 @@ pub async fn pages_create(
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
+1 -3
View File
@@ -253,9 +253,7 @@ pub async fn user_restore_backup_post(
let mut archive = tar::Archive::new(tar_gz);
archive.unpack(&temp_unpack_dir)?;
let target_tenant_id = user
.tenant_id
.unwrap_or_else(crate::identity::TenantId::generate);
let target_tenant_id = user.tenant_id.ok_or("User is missing assigned TenantId")?;
let temp_content_db = temp_unpack_dir.join("content.db");
if !temp_content_db.exists() {
return Err("Backup is missing content.db".into());
+28 -8
View File
@@ -153,9 +153,10 @@ pub async fn user_urls_create(
}
}
let owner_tid = user
.tenant_id
.unwrap_or_else(crate::identity::TenantId::generate);
let owner_tid = match user.tenant_id {
Some(tid) => tid,
None => return (StatusCode::FORBIDDEN, "Missing tenant identity").into_response(),
};
{
let reserved_conn = state.db.reserved.lock().unwrap();
@@ -415,10 +416,10 @@ pub async fn urls_get(
}
}
}
if let Some(u) = resolved_url {
urls.push(u);
let url = if let Some(u) = resolved_url {
u
} else {
urls.push(crate::models::Url {
crate::models::Url {
id: target_id,
code: slug,
destination: String::new(),
@@ -435,8 +436,23 @@ pub async fn urls_get(
expired: false,
last_latency_ms: 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);
@@ -505,7 +521,11 @@ pub async fn urls_create(
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
+26 -8
View File
@@ -164,7 +164,7 @@ pub async fn api_create_url(
let (target_user_id, target_tenant_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
StatusCode::FORBIDDEN,
Json(ApiError {
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()
}
};
let tid = u
.tenant_id
.unwrap_or_else(crate::identity::TenantId::generate);
let tid = match u.tenant_id {
Some(t) => t,
None => {
return (
StatusCode::FORBIDDEN,
Json(ApiError {
error: "User has no assigned TenantId".to_string(),
}),
)
.into_response();
}
};
(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 {
crate::models::ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
StatusCode::FORBIDDEN,
Json(ApiError {
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()
}
};
let tid = u
.tenant_id
.unwrap_or_else(crate::identity::TenantId::generate);
let tid = match u.tenant_id {
Some(t) => t,
None => {
return (
StatusCode::FORBIDDEN,
Json(ApiError {
error: "User has no assigned TenantId".to_string(),
}),
)
.into_response();
}
};
(u.id, tid, user_dbs.content.clone())
}
};
+1 -1
View File
@@ -217,7 +217,7 @@ pub async fn api_bulk_url(
let (target_user_id, target_tenant_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
}),
+2 -2
View File
@@ -1476,13 +1476,13 @@
<li class="{% block active_urls %}{% endblock %}">
<a href="/admin/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
URL Registry
</a>
</li>
<li class="{% block active_pages %}{% endblock %}">
<a href="/admin/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
Landing Page Registry
</a>
</li>
<li class="{% block active_users %}{% endblock %}">
+43 -78
View File
@@ -1,10 +1,10 @@
{% 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 header_title %}Landing Page Registry{% endblock %}
{% block header_title %}Global Landing Page Registry{% endblock %}
{% block content %}
{% if let Some(err) = error %}
@@ -13,79 +13,29 @@
</div>
{% endif %}
<div class="urls-dashboard">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; 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>
Create a New Landing Page
</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>&#10;<html>&#10;<head>&#10; <title>Landing Page</title>&#10;</head>&#10;<body>&#10; <h1>Welcome!</h1>&#10;</body>&#10;</html>" required></textarea>
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
</form>
</div>
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="registered-links-header">
<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"><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>
Registered Pages
Global Landing Page Registry
</h3>
</div>
<div class="table-container">
<table class="registered-pages-table">
<colgroup>
<col class="col-page-title">
<col class="col-page-paths">
<col class="col-page-status">
<col class="col-page-qr">
<col class="col-page-actions">
<col class="col-page-title" style="width: 25%;">
<col class="col-page-paths" style="width: 25%;">
<col class="col-owner" style="width: 15%;">
<col class="col-page-status" style="width: 10%;">
<col class="col-page-qr" style="width: 8%;">
<col class="col-page-actions" style="width: 17%;">
</colgroup>
<thead>
<tr>
<th>Page</th>
<th>Paths</th>
<th>Owner / Tenant</th>
<th>Status</th>
<th>QR Code</th>
<th>Actions</th>
@@ -94,51 +44,67 @@
<tbody>
{% if pages.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages registered. Create one to get started!
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages registered across tenants.
</td>
</tr>
{% else %}
{% for page in pages %}
{% for row 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 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/{{ page.code }}" target="_blank" class="page-path-link">
/p/{{ page.code }}
<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/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
/p/{{ page.code }}/{{ page.slug }}
<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-{{ page.state }}">
{{ page.state }}
<span class="badge badge-{{ row.page.state }}">
{{ row.page.state }}
</span>
</td>
{% let code = page.code.as_str() %}
{% let code = row.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;">
<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/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
<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.4rem 0.6rem; font-size: 0.8rem;">
<button type="submit" class="btn btn-danger" style="padding: 0.3rem 0.5rem; font-size: 0.75rem;">
Delete
</button>
</form>
@@ -177,5 +143,4 @@
{% endif %}
</div>
</div>
</div>
{% endblock %}
+55 -144
View File
@@ -1,18 +1,10 @@
{% extends "layout.html" %}
{% block title %}Manage Short URLs - BZOD{% endblock %}
{% block title %}Global URL Registry - BZOD Admin{% endblock %}
{% block active_urls %}active{% endblock %}
{% block extra_css %}
@media (max-width: 720px) {
#utm_fields > div {
grid-template-columns: 1fr !important;
}
}
{% endblock %}
{% block header_title %}Short URL Registry{% endblock %}
{% block header_title %}Global URL Registry{% endblock %}
{% block content %}
{% if let Some(err) = error %}
@@ -21,94 +13,11 @@
</div>
{% endif %}
<div class="urls-dashboard">
<div class="card url-form-card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; 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>
Shorten a New URL
</h3>
<form action="/admin/urls/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="destination">Destination URL *</label>
<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 class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="registered-links-header">
<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"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Registered Links
Global URL Registry
</h3>
{% if let Some(tag) = tag_filter %}
<span class="badge badge-healthy" style="text-transform: none;">
@@ -121,20 +30,20 @@
<div class="table-container">
<table class="registered-links-table">
<colgroup>
<col class="col-short">
<col class="col-destination">
<col class="col-qr">
<col class="col-health">
<col class="col-tags">
<col class="col-actions">
<col class="col-short" style="width: 22%;">
<col class="col-destination" style="width: 26%;">
<col class="col-owner" style="width: 14%;">
<col class="col-health" style="width: 10%;">
<col class="col-qr" style="width: 8%;">
<col class="col-actions" style="width: 20%;">
</colgroup>
<thead>
<tr>
<th>Short Link</th>
<th>Destination</th>
<th>Owner / Tenant</th>
<th>Status / Health</th>
<th>QR Code</th>
<th>Health</th>
<th>Tags</th>
<th>Actions</th>
</tr>
</thead>
@@ -142,101 +51,104 @@
{% if urls.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No shortened URLs registered. Create one to get started!
No shortened URLs registered across tenants.
</td>
</tr>
{% else %}
{% for url in urls %}
{% for row 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 href="/{{ row.url.code }}" target="_blank" class="short-link-anchor">
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ row.url.code }}
</a>
{% if url.is_password_protected() %}
{% if row.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 let Some(title) = row.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 class="short-link-meta">Created {{ row.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 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) = url.description.as_deref() %}
{% 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(expires_at) = url.expires_at.as_deref() %}
{% if let Some(expires_at) = row.url.expires_at.as_deref() %}
{% if !expires_at.is_empty() %}
<div class="destination-meta expiry">
<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 %}
Expires: {{ expires_at[0..10] }}
{% 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 %}
</div>
{% endif %}
{% endif %}
{% if let Some(max) = url.max_access_count %}
{% if let Some(max) = row.url.max_access_count %}
<div class="destination-meta access">
<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>
Clicks: {{ url.access_count }} / {{ max }}
{% if url.is_access_exhausted() %}
Clicks: {{ row.url.access_count }} / {{ max }}
{% 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 url.access_count > 0 %}
{% if row.url.access_count > 0 %}
<div class="destination-meta neutral">
Clicks: {{ url.access_count }}
Clicks: {{ row.url.access_count }}
</div>
{% 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 }}
<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>
<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 %}
</td>
{% let code = row.url.code.as_str() %}
{% include "components/qr_preview.html" %}
<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;">
<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/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
<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.4rem 0.6rem; font-size: 0.8rem;">
Delete
<button type="submit" class="btn btn-danger" style="padding: 0.3rem 0.5rem; font-size: 0.75rem;">
Retire
</button>
</form>
</div>
@@ -274,5 +186,4 @@
{% endif %}
</div>
</div>
</div>
{% endblock %}
+2 -4
View File
@@ -202,12 +202,10 @@ async fn test_full_http_e2e_flow() {
let res = client.get(&user_dashboard_url).send().await.unwrap();
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 res = client.get(&admin_dashboard_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
let redirect_url = res.headers().get("location").unwrap().to_str().unwrap();
assert_eq!(redirect_url, "/admin/login");
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
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");
}
+3 -23
View File
@@ -158,19 +158,9 @@ async fn test_admin_cannot_create_unowned_application_resources() {
.send()
.await
.unwrap();
assert_eq!(create_url_res.status(), reqwest::StatusCode::SEE_OTHER);
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"
);
assert_eq!(create_url_res.status(), reqwest::StatusCode::FORBIDDEN);
// 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();
page_form.insert("title", "Admin Page");
page_form.insert("slug", "!admin_page");
@@ -186,17 +176,7 @@ async fn test_admin_cannot_create_unowned_application_resources() {
.send()
.await
.unwrap();
assert_eq!(create_page_res.status(), reqwest::StatusCode::SEE_OTHER);
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"
);
assert_eq!(create_page_res.status(), reqwest::StatusCode::FORBIDDEN);
let _ = fs::remove_dir_all(&temp_dir);
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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="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="tp">$</span> <span class="tc">./target/release/bzod serve</span>
@@ -537,7 +537,7 @@ Authorization: Bearer bzo_xxxxxxxxxxxx
<thead>
<tr>
<th>Feature</th>
<th>BZOD v0.5.0</th>
<th>BZOD v0.8.0</th>
<th>Shlink</th>
<th>YOURLS</th>
<th>Chhoto URL</th>