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