Release v0.6.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-09 17:17:57 +05:30
1 parent 7069ca9db7
commit f49698bb5c
75 files changed
+11199 -7508

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
//! Post-restore filesystem layout normalization for multi-tenant BZOD data dirs.
//!
//! Extracted from admin restore handlers so path moves are testable without HTTP.
use std::path::{Path, PathBuf};
use tracing::warn;
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
///
/// Layout:
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
let admin_dir = data_dir.join("admin");
let users_1_dir = data_dir.join("users").join("1");
std::fs::create_dir_all(&admin_dir)?;
std::fs::create_dir_all(&users_1_dir)?;
let admin_files = [
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
"users.db",
"users.db-wal",
"users.db-shm",
];
for f in admin_files {
let src = data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
if let Err(e) = std::fs::rename(&src, &dst) {
warn!(
file = f,
error = %e,
"failed to move restored admin file into admin/"
);
return Err(e);
}
}
}
let content_files = [
"content.db",
"content.db-wal",
"content.db-shm",
"analytics.db",
"analytics.db-wal",
"analytics.db-shm",
];
for f in content_files {
let src = data_dir.join(f);
if src.exists() {
let dst = users_1_dir.join(f);
if let Err(e) = std::fs::rename(&src, &dst) {
warn!(
file = f,
error = %e,
"failed to move restored content file into users/1/"
);
return Err(e);
}
}
}
Ok(())
}
/// Paths used when reopening connections after restore.
#[derive(Debug, Clone)]
pub struct RestoredDbPaths {
pub admin: PathBuf,
pub system: PathBuf,
pub users: PathBuf,
pub content: PathBuf,
pub analytics: PathBuf,
}
impl RestoredDbPaths {
pub fn from_data_dir(data_dir: &Path) -> Self {
Self {
admin: data_dir.join("admin/admin.db"),
system: data_dir.join("admin/system.db"),
users: data_dir.join("admin/users.db"),
content: data_dir.join("users/1/content.db"),
analytics: data_dir.join("users/1/analytics.db"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn moves_flat_files_into_tenant_layout() {
let dir = std::env::temp_dir().join(format!("bzod_layout_{}", uuid::Uuid::new_v4()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("admin.db"), b"a").unwrap();
fs::write(dir.join("system.db"), b"s").unwrap();
fs::write(dir.join("users.db"), b"u").unwrap();
fs::write(dir.join("content.db"), b"c").unwrap();
fs::write(dir.join("analytics.db"), b"an").unwrap();
normalize_restored_layout(&dir).unwrap();
assert!(dir.join("admin/admin.db").exists());
assert!(dir.join("admin/system.db").exists());
assert!(dir.join("admin/users.db").exists());
assert!(dir.join("users/1/content.db").exists());
assert!(dir.join("users/1/analytics.db").exists());
assert!(!dir.join("admin.db").exists());
assert!(!dir.join("content.db").exists());
let _ = fs::remove_dir_all(&dir);
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Bulk URL creation business logic (transaction + slug reservation).
//!
//! Handlers own auth/HTTP; this module owns validation, reservation, and inserts.
use crate::auth::generate_token;
use crate::auth::password::hash_password;
use crate::models::Url;
use crate::utils::validation::validate_redirect_destination;
use rusqlite::{Connection, Transaction};
use std::sync::Mutex;
/// One item in a bulk URL create request (mirrors the HTTP payload shape).
#[derive(Debug, Clone)]
pub struct BulkUrlCreateItem {
pub destination: String,
pub code: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub expires_at: Option<String>,
pub password: Option<String>,
pub max_access_count: Option<i64>,
}
#[derive(Debug)]
pub enum BulkUrlError {
BadRequest(String),
Conflict(String),
Forbidden(String),
Internal(String),
}
impl BulkUrlError {
pub fn message(&self) -> &str {
match self {
Self::BadRequest(m) | Self::Conflict(m) | Self::Forbidden(m) | Self::Internal(m) => m,
}
}
}
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
for slug in slugs {
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
}
}
/// Check that the tenant can accept `additional` new URLs.
pub fn ensure_url_quota(
users_db: &Mutex<Connection>,
user_id: i64,
additional: i64,
) -> Result<(), BulkUrlError> {
let users_conn = crate::utils::lock_db(users_db, "users_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
match crate::db::users::get_user_quotas(&users_conn, user_id) {
Ok(Some(quotas)) => {
if quotas.current_urls + additional > quotas.max_urls {
Err(BulkUrlError::Forbidden("Quota limit exceeded".into()))
} else {
Ok(())
}
}
Ok(None) => Err(BulkUrlError::Forbidden("User quota not found".into())),
Err(e) => Err(BulkUrlError::Internal(format!("quota lookup failed: {e}"))),
}
}
/// Create many URLs inside a single content transaction with global slug reservation.
pub fn create_urls_bulk(
content_db: &Mutex<Connection>,
system_db: &Mutex<Connection>,
users_db: &Mutex<Connection>,
owner_user_id: i64,
items: Vec<BulkUrlCreateItem>,
) -> Result<Vec<Url>, BulkUrlError> {
let mut conn = crate::utils::lock_db(content_db, "content_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let tx = conn.transaction().map_err(|e| {
BulkUrlError::Internal(format!("Failed to start database transaction: {e}"))
})?;
let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in items {
match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) {
Ok(url) => created_urls.push(url),
Err(e) => {
let _ = tx.rollback();
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
}
return Err(e);
}
}
}
if let Err(e) = tx.commit() {
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
}
return Err(BulkUrlError::Internal(format!(
"Failed to commit transaction: {e}"
)));
}
// Activate slugs
{
let system_conn = crate::utils::lock_db(system_db, "system_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for url in &created_urls {
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
);
}
}
// Increment quota counters
{
let users_conn = crate::utils::lock_db(users_db, "users_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, owner_user_id, "urls");
}
}
Ok(created_urls)
}
fn create_one_in_tx(
tx: &Transaction<'_>,
system_db: &Mutex<Connection>,
owner_user_id: i64,
item: BulkUrlCreateItem,
reserved_slugs: &mut Vec<String>,
) -> Result<Url, BulkUrlError> {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(BulkUrlError::BadRequest(format!(
"Short code '{code}' must be 6 hex characters"
)));
}
{
let system_conn = crate::utils::lock_db(system_db, "system_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
return Err(BulkUrlError::Conflict(format!(
"Short code '{code}' already exists"
)));
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
owner_user_id,
"url",
"",
"reserving",
) {
return Err(BulkUrlError::Internal(format!(
"Failed to reserve slug '{code}': {e}"
)));
}
reserved_slugs.push(code.clone());
}
let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
return Err(BulkUrlError::Internal(format!(
"Password hashing error: {e}"
)));
}
}
} else {
None
};
if !validate_redirect_destination(&item.destination) {
return Err(BulkUrlError::BadRequest(format!(
"Invalid destination for item '{code}': must be a valid http(s) URL without control characters"
)));
}
let tags = item.tags.unwrap_or_default();
crate::db::content::create_url_extended(
tx,
&code,
&item.destination,
item.title.as_deref(),
item.description.as_deref(),
&tags,
item.expires_at.as_deref(),
password_hash.as_deref(),
item.max_access_count,
)
.map_err(|e| BulkUrlError::Internal(format!("Database insert error: {e}")))
}
+261
View File
@@ -0,0 +1,261 @@
//! Read-only audit of stored redirect destinations.
//!
//! Scans tenant content databases and classifies each `urls.destination` using
//! the same rules as write-path validation. Never rewrites or deletes data.
use crate::db::Db;
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
use rusqlite::Connection;
use std::path::Path;
use tracing::{error, info, warn};
/// Summary counters for a destination audit run.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DestinationAuditReport {
pub scanned_users: usize,
pub total_urls: usize,
pub valid_http: usize,
pub valid_https: usize,
pub invalid: usize,
pub control_characters: usize,
pub unsupported_scheme: usize,
pub malformed: usize,
pub empty: usize,
pub too_long: usize,
pub non_ascii: usize,
/// Safe sample of invalid records: (owner_user_id, code, class_label).
/// Destination bodies are never included (may contain control chars / secrets).
pub invalid_samples: Vec<InvalidDestinationSample>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidDestinationSample {
pub owner_user_id: i64,
pub code: String,
pub url_id: String,
pub class: &'static str,
pub destination_len: usize,
}
const MAX_SAMPLES: usize = 50;
fn class_label(c: DestinationClass) -> &'static str {
match c {
DestinationClass::ValidHttp => "valid_http",
DestinationClass::ValidHttps => "valid_https",
DestinationClass::Empty => "empty",
DestinationClass::TooLong => "too_long",
DestinationClass::ControlCharacters => "control_characters",
DestinationClass::NonAscii => "non_ascii",
DestinationClass::UnsupportedScheme => "unsupported_scheme",
DestinationClass::Malformed => "malformed",
}
}
/// Classify a single destination and update report counters.
pub fn record_destination(
report: &mut DestinationAuditReport,
owner_user_id: i64,
code: &str,
url_id: &str,
destination: &str,
) {
report.total_urls += 1;
let class = classify_redirect_destination(destination);
match class {
DestinationClass::ValidHttp => report.valid_http += 1,
DestinationClass::ValidHttps => report.valid_https += 1,
DestinationClass::Empty => {
report.empty += 1;
report.invalid += 1;
}
DestinationClass::TooLong => {
report.too_long += 1;
report.invalid += 1;
}
DestinationClass::ControlCharacters => {
report.control_characters += 1;
report.invalid += 1;
}
DestinationClass::NonAscii => {
report.non_ascii += 1;
report.invalid += 1;
}
DestinationClass::UnsupportedScheme => {
report.unsupported_scheme += 1;
report.invalid += 1;
}
DestinationClass::Malformed => {
report.malformed += 1;
report.invalid += 1;
}
}
if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES {
report.invalid_samples.push(InvalidDestinationSample {
owner_user_id,
code: code.to_string(),
url_id: url_id.to_string(),
class: class_label(class),
destination_len: destination.len(),
});
}
}
/// Scan one content database connection for URL destinations.
pub fn audit_content_conn(
conn: &Connection,
owner_user_id: i64,
report: &mut DestinationAuditReport,
) -> rusqlite::Result<()> {
let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for row in rows {
let (id, code, destination) = row?;
record_destination(report, owner_user_id, &code, &id, &destination);
}
Ok(())
}
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> {
let path = data_dir
.join("users")
.join(user_id.to_string())
.join("content.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
let conn = Connection::open(path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
Ok(conn)
}
/// Audit all tenant content databases found under the configured data directory.
///
/// Read-only: does not modify any records.
pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String> {
let mut report = DestinationAuditReport::default();
let user_ids: Vec<i64> = {
let users = db
.users
.lock()
.map_err(|e| format!("users_db lock poisoned: {}", e))?;
let mut stmt = users
.prepare("SELECT id FROM users;")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for user_id in user_ids {
match open_user_content(&db.data_dir, user_id) {
Ok(conn) => {
report.scanned_users += 1;
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
error!(
owner_user_id = user_id,
error = %e,
"destination audit failed for user content.db"
);
return Err(format!("audit user {} content.db: {}", user_id, e));
}
}
Err(rusqlite::Error::InvalidPath(_)) => {
// User has no content DB yet — skip.
}
Err(e) => {
warn!(
owner_user_id = user_id,
error = %e,
"could not open user content.db for destination audit"
);
}
}
}
info!(
total_urls = report.total_urls,
valid = report.valid_http + report.valid_https,
invalid = report.invalid,
"destination audit complete"
);
Ok(report)
}
/// Format a human-readable report for CLI output.
pub fn format_report(report: &DestinationAuditReport) -> String {
let mut out = String::new();
out.push_str("BZOD Redirect Destination Audit (read-only)\n");
out.push_str("===========================================\n");
out.push_str(&format!("Users scanned: {}\n", report.scanned_users));
out.push_str(&format!("Total URLs: {}\n", report.total_urls));
out.push_str(&format!("Valid HTTP: {}\n", report.valid_http));
out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https));
out.push_str(&format!("Invalid (total): {}\n", report.invalid));
out.push_str(&format!(
" control characters: {}\n",
report.control_characters
));
out.push_str(&format!(
" unsupported scheme: {}\n",
report.unsupported_scheme
));
out.push_str(&format!(" malformed: {}\n", report.malformed));
out.push_str(&format!(" empty: {}\n", report.empty));
out.push_str(&format!(" too long: {}\n", report.too_long));
out.push_str(&format!(" non-ascii: {}\n", report.non_ascii));
if !report.invalid_samples.is_empty() {
out.push_str("\nInvalid samples (id/code only; destinations not printed):\n");
for s in &report.invalid_samples {
out.push_str(&format!(
" user={} code={} id={} class={} dest_len={}\n",
s.owner_user_id, s.code, s.url_id, s.class, s.destination_len
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_control_character_destination() {
let mut report = DestinationAuditReport::default();
record_destination(
&mut report,
1,
"ab12cd",
"id-1",
"https://evil.example/\r\nX:1",
);
assert_eq!(report.total_urls, 1);
assert_eq!(report.invalid, 1);
assert_eq!(report.control_characters, 1);
assert_eq!(report.invalid_samples.len(), 1);
assert_eq!(report.invalid_samples[0].class, "control_characters");
// Ensure we never store the destination body in the sample.
assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil"));
}
#[test]
fn records_valid_https() {
let mut report = DestinationAuditReport::default();
record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok");
assert_eq!(report.valid_https, 1);
assert_eq!(report.invalid, 0);
assert!(report.invalid_samples.is_empty());
}
}
+5
View File
@@ -1,7 +1,12 @@
pub mod api_keys;
pub mod audit;
pub mod backup_layout;
pub mod bulk;
pub mod bulk_urls;
pub mod destination_audit;
pub mod landing_pages;
pub mod qr;
pub mod registry_validator;
pub mod shortener;
pub mod slug_transfer;
pub mod urls;
+13 -2
View File
@@ -10,13 +10,24 @@ pub fn create_url(
description: Option<&str>,
tags: &[String],
) -> Result<Url, AppError> {
let conn = db.content.lock().unwrap();
if !crate::utils::validation::validate_redirect_destination(destination) {
return Err(AppError::BadRequest(
"Destination must be a valid http(s) URL without control characters".into(),
));
}
let conn = db
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
Ok(url)
}
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
let conn = db.content.lock().unwrap();
let conn = db
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::get_url_by_code(&conn, code)?;
Ok(url)
}
+243
View File
@@ -0,0 +1,243 @@
//! Cross-tenant slug transfer business logic.
//!
//! Copies URL/page content between tenant content DBs, then updates global_slugs
//! ownership. Handlers own admin auth and HTTP mapping.
use crate::state::{AppState, UserDbs};
use crate::utils::lock_db;
use chrono::Utc;
use rusqlite::OptionalExtension;
#[derive(Debug)]
pub enum TransferError {
NotFound(&'static str),
BadRequest(String),
Internal(String),
}
impl TransferError {
pub fn message(&self) -> String {
match self {
Self::NotFound(m) => (*m).to_string(),
Self::BadRequest(m) | Self::Internal(m) => m.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct SlugTransferRequest {
pub slug: String,
pub new_owner_user_id: i64,
}
#[derive(Debug)]
pub struct SlugTransferResult {
pub old_owner_user_id: i64,
pub new_owner_user_id: i64,
pub target_type: String,
pub new_target_id: String,
}
/// Look up slug ownership in `global_slugs`.
pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> {
let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let mut stmt = system_conn
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let row_opt = stmt
.query_row([slug], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional()
.map_err(|e| TransferError::Internal(e.to_string()))?;
match row_opt {
Some(r) => Ok(r),
None => Err(TransferError::NotFound("Slug not found")),
}
}
/// Copy content row between tenants and return the new target id.
fn copy_content(
state: &AppState,
old_dbs: &UserDbs,
new_dbs: &UserDbs,
slug: &str,
target_type: &str,
new_owner_user_id: i64,
) -> Result<String, TransferError> {
let old_conn = lock_db(&old_dbs.content, "old_content_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let new_conn = lock_db(&new_dbs.content, "new_content_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if target_type == "url" {
let url = match crate::db::content::get_url_by_code(&old_conn, slug) {
Ok(Some(u)) => u,
Ok(None) => {
return Err(TransferError::NotFound(
"Content not found in owner database",
))
}
Err(e) => return Err(TransferError::Internal(e.to_string())),
};
{
let new_users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if let Ok(Some(quota)) =
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
{
if quota.current_urls >= quota.max_urls {
return Err(TransferError::BadRequest(
"New owner has exceeded URL quota limit".into(),
));
}
}
}
let new_url = crate::db::content::create_url_extended(
&new_conn,
&url.code,
&url.destination,
url.title.as_deref(),
url.description.as_deref(),
&url.tags,
url.expires_at.as_deref(),
url.password_hash.as_deref(),
url.max_access_count,
)
.map_err(|e| TransferError::Internal(format!("Failed to copy URL to new owner: {e}")))?;
let _ = crate::db::content::delete_url(&old_conn, &url.id);
Ok(new_url.id)
} else if target_type == "page" {
let page = match crate::db::content::get_landing_page_by_code(&old_conn, slug) {
Ok(Some(p)) => p,
Ok(None) => {
return Err(TransferError::NotFound(
"Content not found in owner database",
))
}
Err(e) => return Err(TransferError::Internal(e.to_string())),
};
{
let new_users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if let Ok(Some(quota)) =
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
{
if quota.current_landings >= quota.max_landings {
return Err(TransferError::BadRequest(
"New owner has exceeded landing page quota limit".into(),
));
}
}
}
let new_page = crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
)
.map_err(|e| TransferError::Internal(format!("Failed to copy Page to new owner: {e}")))?;
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
Ok(new_page.id)
} else {
Err(TransferError::NotFound(
"Content not found in owner database",
))
}
}
/// Perform a full slug transfer (content + registry + quotas + history).
pub fn transfer_slug(
state: &AppState,
req: &SlugTransferRequest,
admin_username: &str,
) -> Result<SlugTransferResult, TransferError> {
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
if old_owner_user_id == req.new_owner_user_id {
return Err(TransferError::BadRequest(
"New owner must be different from the current owner".into(),
));
}
let old_dbs = state
.get_user_dbs(old_owner_user_id)
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
let new_dbs = state
.get_user_dbs(req.new_owner_user_id)
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
let new_target_id = copy_content(
state,
&old_dbs,
&new_dbs,
&req.slug,
&target_type,
req.new_owner_user_id,
)?;
{
let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug],
);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![
req.slug,
old_owner_user_id,
req.new_owner_user_id,
now,
admin_username
],
);
let users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let field = if target_type == "url" {
"urls"
} else {
"landings"
};
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
let _ =
crate::db::users::increment_quota_counter(&users_conn, req.new_owner_user_id, field);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
admin_username,
"SLUG_TRANSFER",
"slug",
&req.slug,
Some(&format!(
"From owner {} to owner {}",
old_owner_user_id, req.new_owner_user_id
)),
);
}
Ok(SlugTransferResult {
old_owner_user_id,
new_owner_user_id: req.new_owner_user_id,
target_type,
new_target_id,
})
}
+117
View File
@@ -0,0 +1,117 @@
//! Shared URL write-path helpers used by admin UI, tenant UI, and REST API.
//!
//! Handlers remain responsible for auth/CSRF/quotas; this module owns pure
//! destination preparation that must stay consistent across entry points.
use crate::utils::validation::validate_redirect_destination;
/// Optional UTM parameters applied to a destination URL.
#[derive(Debug, Default, Clone)]
pub struct UtmParams<'a> {
pub source: Option<&'a str>,
pub medium: Option<&'a str>,
pub campaign: Option<&'a str>,
}
/// Normalize and optionally append UTM parameters to a destination.
///
/// Returns `Err` when the base destination fails canonical validation.
/// UTM appending only runs when the base parses as a URL (same as prior handlers).
pub fn prepare_destination(raw: &str, utm: UtmParams<'_>) -> Result<String, &'static str> {
let mut dest = raw.trim().to_string();
if !validate_redirect_destination(&dest) {
return Err("Destination must be a valid http(s) URL without control characters");
}
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
let mut has_utm = false;
{
let mut query = parsed.query_pairs_mut();
if let Some(src) = utm.source {
let src = src.trim();
if !src.is_empty() {
query.append_pair("utm_source", src);
has_utm = true;
}
}
if let Some(med) = utm.medium {
let med = med.trim();
if !med.is_empty() {
query.append_pair("utm_medium", med);
has_utm = true;
}
}
if let Some(camp) = utm.campaign {
let camp = camp.trim();
if !camp.is_empty() {
query.append_pair("utm_campaign", camp);
has_utm = true;
}
}
}
if has_utm {
dest = parsed.to_string();
}
}
Ok(dest)
}
/// Parse HTML datetime-local / partial RFC3339 expiry input into RFC3339 if present.
pub fn parse_expires_at_input(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut rfc = trimmed.to_string();
if rfc.len() == 16 {
// HTML datetime-local → assume UTC seconds
rfc.push_str(":00Z");
}
Some(rfc)
}
/// Parse optional max-access-count form field.
pub fn parse_max_access_count(raw: &str) -> Option<i64> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prepare_rejects_crlf() {
let err = prepare_destination("https://x/\r\nY:1", UtmParams::default()).unwrap_err();
assert!(err.contains("valid http"));
}
#[test]
fn prepare_appends_utm() {
let dest = prepare_destination(
"https://example.com/path",
UtmParams {
source: Some("newsletter"),
medium: Some("email"),
campaign: Some("spring"),
},
)
.unwrap();
assert!(dest.contains("utm_source=newsletter"));
assert!(dest.contains("utm_medium=email"));
assert!(dest.contains("utm_campaign=spring"));
}
#[test]
fn parse_expires_datetime_local() {
assert_eq!(
parse_expires_at_input("2030-01-01T12:00"),
Some("2030-01-01T12:00:00Z".to_string())
);
assert_eq!(parse_expires_at_input(" "), None);
}
}