Release v0.5.1: namespace integrity, dashboard parity and QR hardening

This commit is contained in:
thakares committed 2026-06-20 19:54:29 +05:30
1 parent 0295b4bd7c
commit 115f6e9a23
45 files changed
+5851 -1765

No files matched your search

+67 -3
View File
@@ -22,11 +22,23 @@ pub async fn run(
println!("Data directory: {:?}", config.data_dir);
println!();
let databases = ["admin", "content", "analytics", "system"];
let mut all_healthy = true;
for db_name in &databases {
let db_path = config.data_dir.join(format!("{}.db", db_name));
// Define target databases in the new layout
let admin_dir = config.data_dir.join("admin");
let dbs = vec![
("admin", admin_dir.join("admin.db")),
("system", admin_dir.join("system.db")),
("users", admin_dir.join("users.db")),
("legacy content", config.data_dir.join("content.db")),
("legacy analytics", config.data_dir.join("analytics.db")),
];
for (db_name, db_path) in dbs {
// Skip legacy databases if they don't exist
if db_name.starts_with("legacy") && !db_path.exists() {
continue;
}
if !db_path.exists() {
println!("Database: {}", db_name);
@@ -75,6 +87,58 @@ pub async fn run(
println!();
}
// Global Slug Registry Integrity Check
println!("Global Slug Registry Integrity Check");
println!("====================================");
let system_db_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
if system_db_path.exists() && users_db_path.exists() {
match (
Connection::open(&system_db_path),
Connection::open(&users_db_path),
) {
(Ok(sys_conn), Ok(usr_conn)) => {
match crate::db::users::verify_global_slug_registry_integrity(
&sys_conn,
&usr_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
if errors.is_empty() && warnings.is_empty() {
println!(" Status: HEALTHY (no issues found)");
} else {
if !errors.is_empty() {
println!(" Errors (Action Required):");
for err in &errors {
println!(" - {}", err);
}
all_healthy = false;
}
if !warnings.is_empty() {
println!(" Warnings (Attention Needed):");
for warn in &warnings {
println!(" - {}", warn);
}
}
}
}
Err(e) => {
println!(" Status: ERROR running integrity check: {}", e);
all_healthy = false;
}
}
}
_ => {
println!(" Status: ERROR opening system.db or users.db for integrity check");
all_healthy = false;
}
}
} else {
println!(" Status: SKIPPED (system.db/users.db not found)");
}
println!();
println!("--------------------");
if all_healthy {
println!("Overall status: HEALTHY");
+83 -22
View File
@@ -15,34 +15,95 @@ pub fn perform_restore(
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
// 2. Validate that the archive contains the expected BZOD database files
let mut has_admin = false;
let mut has_content = false;
let mut has_analytics = false;
let mut has_system = false;
// 2. Unpack to temporary directory first
let temp_dir =
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir)?;
for entry_res in archive.entries()? {
let entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match file_name {
"admin.db" => has_admin = true,
"content.db" => has_content = true,
"analytics.db" => has_analytics = true,
"system.db" => has_system = true,
_ => {}
if let Err(e) = archive.unpack(&temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(e.into());
}
// 3. Run validation on temp_dir
let mut temp_config = Config::load();
temp_config.data_dir = temp_dir.clone();
// Namespace audit
match crate::db::users::audit_slug_namespace(&temp_config) {
Ok(report) => {
if !report.duplicates.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(
format!("Slug conflicts detected in backup: {:?}", report.duplicates).into(),
);
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to audit slug namespace in backup: {}", e).into());
}
}
if !has_admin || !has_content || !has_analytics || !has_system {
return Err("Archive is missing one or more required database files (admin.db, content.db, analytics.db, system.db)".into());
// Registry integrity check
let system_db_path = if temp_dir.join("admin/system.db").exists() {
temp_dir.join("admin/system.db")
} else {
temp_dir.join("system.db")
};
let users_db_path = if temp_dir.join("admin/users.db").exists() {
temp_dir.join("admin/users.db")
} else {
temp_dir.join("users.db")
};
if system_db_path.exists() && users_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?;
let users_conn = rusqlite::Connection::open(&users_db_path)?;
match crate::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&temp_dir,
) {
Ok((errors, _warnings)) => {
if !errors.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to verify registry integrity in backup: {}", e).into());
}
}
}
// 3. Unpack archive to data_dir
let f2 = File::open(file_path)?;
let tar_gz2 = GzDecoder::new(f2);
let mut archive2 = Archive::new(tar_gz2);
archive2.unpack(data_dir)?;
// 4. If validation succeeds, copy temp_dir contents to data_dir
if data_dir.exists() {
let _ = std::fs::remove_dir_all(data_dir);
}
std::fs::create_dir_all(data_dir)?;
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
} else {
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
}
}
Ok(())
}
if let Err(e) = copy_dir_all(&temp_dir, data_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to copy restored files: {}", e).into());
}
let _ = std::fs::remove_dir_all(&temp_dir);
Ok(())
}
+7 -94
View File
@@ -1,7 +1,5 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use rusqlite::OptionalExtension;
use std::fs::File;
use std::path::PathBuf;
use tar::Archive;
@@ -158,103 +156,18 @@ pub async fn run(
}
}
// 4. Register slugs in global_slugs
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
// 4. Register slugs in global_slugs using the shared helper
{
let mut system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?;
// Delete any existing global slugs owned by this user
tx.execute(
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
[target_user_id],
let system_conn = db.system.lock().unwrap();
crate::db::users::register_restored_user_slugs(
&system_conn,
target_user_id,
&dest_dir.join("content.db"),
)?;
// Register URLs
{
let mut stmt =
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let existing_owner: Option<i64> = tx
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[&slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
error!(
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
slug, owner
);
continue;
}
}
tx.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
}
// Register Landing Pages
{
let mut stmt = restored_content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let existing_owner: Option<i64> = tx
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[&slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
error!(
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
slug, owner
);
continue;
}
}
tx.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
}
tx.commit()?;
}
// 5. Reconcile quotas for restored user
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(),
target_user_id,
+29 -7
View File
@@ -33,7 +33,16 @@ pub async fn run(
None => crate::utils::random::generate_token(3),
};
// 3. Persist URL
// 3. Register slug in system.db with status 'reserving' and check availability
{
let system_conn = db.system.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code)? {
return Err("Short code/slug already exists".into());
}
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
}
// 4. Persist URL
let conn = db.content.lock().unwrap();
let res = crate::db::content::create_url_extended(
&conn,
@@ -48,7 +57,21 @@ pub async fn run(
);
match res {
Ok(_) => {
Ok(url) => {
// Activate slug in system.db
{
let system_conn = db.system.lock().unwrap();
system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
)?;
}
// Increment quota for user ID 1
{
let users_conn = db.users.lock().unwrap();
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
}
let proto = if config.cookie_secure {
"https"
} else {
@@ -63,11 +86,10 @@ pub async fn run(
println!("{}/{}", base_url, code);
Ok(())
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Err("Short code/slug already exists".into())
Err(e) => {
let system_conn = db.system.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Err(e.into())
}
Err(e) => Err(e.into()),
}
}
+58
View File
@@ -68,6 +68,26 @@ impl Db {
}
}
// Pre-migration safety net: audit slug namespace for duplicates / format errors
match crate::db::users::audit_slug_namespace(config) {
Ok(report) => {
if !report.duplicates.is_empty() {
tracing::error!(
"Namespace conflicts detected before database migration: {:?}",
report.duplicates
);
return Err(format!(
"Database upgrade aborted due to slug conflicts: {:?}",
report.duplicates
)
.into());
}
}
Err(e) => {
tracing::warn!("Failed to audit slug namespace before migration: {}", e);
}
}
let admin_path = admin_dir.join("admin.db");
let system_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
@@ -317,6 +337,44 @@ impl Db {
let _ = db.reconcile_global_slugs(config);
// Post-init: Clean up stale reservations
{
let system_conn = db.system.lock().unwrap();
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
Ok(count) => {
if count > 0 {
tracing::info!("Cleaned up {} stale reserving slugs", count);
}
}
Err(e) => {
tracing::error!("Failed to clean up stale reservations: {}", e);
}
}
}
// Post-init: Verify global registry integrity
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
for err in errors {
tracing::error!("Global registry integrity error: {}", err);
}
for warn in warnings {
tracing::warn!("Global registry integrity warning: {}", warn);
}
}
Err(e) => {
tracing::error!("Failed to verify global registry integrity: {}", e);
}
}
}
Ok(db)
}
+469 -2
View File
@@ -303,6 +303,19 @@ pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Opti
.optional()
}
pub fn check_quota_limit(conn: &Connection, user_id: i64, field: &str) -> rusqlite::Result<bool> {
if let Some(quotas) = get_user_quotas(conn, user_id)? {
match field {
"urls" => Ok(quotas.current_urls < quotas.max_urls),
"landings" => Ok(quotas.current_landings < quotas.max_landings),
"api_tokens" => Ok(quotas.current_api_tokens < quotas.max_api_tokens),
_ => Ok(false),
}
} else {
Ok(false)
}
}
pub fn update_user_quotas(
conn: &Connection,
user_id: i64,
@@ -458,12 +471,13 @@ pub fn register_global_slug(
owner_user_id: i64,
target_type: &str,
target_id: &str,
status: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"],
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, status],
)?;
// Insert history
@@ -501,7 +515,7 @@ pub fn soft_delete_global_slug(
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;",
"UPDATE global_slugs SET status = 'disabled', deleted_at = ?1 WHERE slug = ?2;",
rusqlite::params![now, slug],
)?;
@@ -515,6 +529,459 @@ pub fn soft_delete_global_slug(
Ok(())
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SlugAuditReport {
pub duplicates: Vec<String>,
pub invalid_entries: Vec<String>,
pub warnings: Vec<String>,
}
pub fn audit_slug_namespace(
config: &crate::config::Config,
) -> Result<SlugAuditReport, Box<dyn std::error::Error>> {
use std::collections::HashMap;
let mut duplicates = Vec::new();
let mut invalid_entries = Vec::new();
let warnings = Vec::new();
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
// 1. Scan legacy content.db if it exists
let legacy_content_path = config.data_dir.join("content.db");
if legacy_content_path.exists() {
if let Ok(conn) = Connection::open(&legacy_content_path) {
// URLs
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
}
}
}
}
// Landing Pages
if let Ok(mut stmt) = conn.prepare("SELECT code FROM landing_pages;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1);
}
}
}
}
}
}
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
let users_dir = config.data_dir.join("users");
if users_dir.exists() {
for entry in std::fs::read_dir(users_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
if let Ok(user_id) = name_str.parse::<i64>() {
let content_db_path = path.join("content.db");
if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) {
// URLs
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
}
}
}
}
// Landing pages
if let Ok(mut stmt) =
conn.prepare("SELECT code FROM landing_pages;")
{
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
}
}
}
}
}
}
}
}
}
}
}
// 3. Populate report
for (slug, owners) in slug_owners {
if owners.len() > 1 {
duplicates.push(format!(
"Slug '{}' is defined in multiple content databases by owners {:?}",
slug, owners
));
}
// Validate slug format
let valid_url = crate::utils::validation::validate_redirect_code(&slug);
let valid_page = crate::utils::validation::validate_page_code(&slug);
if !valid_url && !valid_page {
invalid_entries.push(format!("Slug '{}' is format-invalid", slug));
}
}
Ok(SlugAuditReport {
duplicates,
invalid_entries,
warnings,
})
}
pub fn cleanup_stale_reservations(
system_conn: &Connection,
data_dir: &std::path::Path,
) -> Result<usize, Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut cleaned_count = 0;
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, created_at FROM global_slugs WHERE status = 'reserving';"
)?;
let mut rows = stmt.query([])?;
let mut stale_slugs = Vec::new();
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let created_at_str: String = row.get(3)?;
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
// Check if target record exists by looking up code = slug in owner's content.db
let content_db_path = if owner_user_id == 1 {
let p1 = data_dir.join("users").join("1").join("content.db");
if p1.exists() {
p1
} else {
data_dir.join("content.db")
}
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
let mut target_exists = false;
if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) {
if target_type == "url" {
target_exists = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE code = ?1);",
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
} else if target_type == "page" {
target_exists = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE code = ?1);",
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
}
}
}
if !target_exists {
stale_slugs.push((slug, owner_user_id));
}
}
}
}
drop(rows);
drop(stmt);
for (slug, owner_user_id) in stale_slugs {
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug])?;
let now = Utc::now().to_rfc3339();
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'released', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
cleaned_count += 1;
}
Ok(cleaned_count)
}
pub fn verify_global_slug_registry_integrity(
system_conn: &Connection,
users_conn: &Connection,
data_dir: &std::path::Path,
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut errors = Vec::new();
let mut warnings = Vec::new();
// 1. Check duplicate slugs
let total_count: i64 =
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
let distinct_count: i64 =
system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| {
r.get(0)
})?;
if total_count != distinct_count {
errors.push(format!(
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
));
}
// 2. Scan all global slugs
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let target_id: String = row.get(3)?;
let created_at_str: String = row.get(4)?;
let status: String = row.get(5)?;
// Target type check
if target_type != "url" && target_type != "page" {
errors.push(format!(
"Slug '{}' has invalid target_type '{}'",
slug, target_type
));
}
// Status check
if status != "active" && status != "disabled" && status != "reserving" {
errors.push(format!("Slug '{}' has invalid status '{}'", slug, status));
}
// Check owner
let owner_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_user_id],
|r| r.get(0),
)
.unwrap_or(false);
if !owner_exists {
errors.push(format!(
"Slug '{}' references missing owner user ID {}",
slug, owner_user_id
));
continue;
}
// Stale warning check
if status == "reserving" {
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
warnings.push(format!(
"Reserving slug '{}' has been stale for over 15 minutes",
slug
));
}
}
}
// Check target record exists for active / disabled (and reserving with target_id)
if status == "active"
|| status == "disabled"
|| (status == "reserving" && !target_id.is_empty())
{
let content_db_path = if owner_user_id == 1 {
let p1 = data_dir.join("users").join("1").join("content.db");
if p1.exists() {
p1
} else {
data_dir.join("content.db")
}
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
if !content_db_path.exists() {
errors.push(format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
));
} else {
match Connection::open(&content_db_path) {
Ok(conn) => {
let exists = if target_type == "url" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else if target_type == "page" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
false
};
if !exists {
errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id));
}
}
Err(e) => {
errors.push(format!(
"Slug '{}' owner content database could not be opened: {}",
slug, e
));
}
}
}
}
}
Ok((errors, warnings))
}
pub fn register_restored_user_slugs(
system_conn: &Connection,
target_user_id: i64,
restored_content_db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let restored_content_conn = Connection::open(restored_content_db_path)?;
let mut urls = Vec::new();
let mut landing_pages = Vec::new();
// 1. Read URLs
{
let mut stmt =
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
urls.push((code, id, created_at, status));
}
}
// 2. Read Landing Pages
{
let mut stmt = restored_content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
landing_pages.push((code, id, created_at, state));
}
}
// 3. Check for collisions across all URLs and landing pages
let mut conflicting_slugs = Vec::new();
for (slug, _, _, _) in &urls {
let existing_owner: Option<i64> = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
conflicting_slugs.push(slug.clone());
}
}
}
for (slug, _, _, _) in &landing_pages {
let existing_owner: Option<i64> = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
conflicting_slugs.push(slug.clone());
}
}
}
if !conflicting_slugs.is_empty() {
return Err(format!(
"Restore failed. Conflicting slugs: {}",
conflicting_slugs.join(", ")
)
.into());
}
// 4. Perform registration
system_conn.execute(
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
[target_user_id],
)?;
for (slug, target_id, created_at, status) in urls {
let now = Utc::now().to_rfc3339();
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
system_conn.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, global_status],
)?;
}
for (slug, target_id, created_at, state) in landing_pages {
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
system_conn.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
Ok(())
}
pub fn reconcile_user_quotas(
users_conn: &Connection,
user_id: i64,
+2
View File
@@ -42,6 +42,7 @@ pub struct UrlAnalyticsTemplate {
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub is_admin: bool,
}
impl UrlAnalyticsTemplate {
@@ -84,6 +85,7 @@ pub struct PageAnalyticsTemplate {
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub is_admin: bool,
}
impl PageAnalyticsTemplate {
+2 -46
View File
@@ -276,6 +276,8 @@ pub struct HealthTemplate {
pub tenants_db_size: String,
pub job_history: Vec<crate::web::admin::JobHistoryRow>,
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
pub registry_errors: Vec<String>,
pub registry_warnings: Vec<String>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
@@ -372,49 +374,3 @@ impl IntoResponse for UserAnalyticsTemplate {
}
}
}
#[derive(Template)]
#[template(path = "user_url_analytics.html")]
pub struct UserUrlAnalyticsTemplate {
pub admin_username: String,
pub username: String,
pub url_code: String,
pub destination: String,
pub visits: Vec<VisitorLogEntry>,
}
impl IntoResponse for UserUrlAnalyticsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "user_page_analytics.html")]
pub struct UserPageAnalyticsTemplate {
pub admin_username: String,
pub username: String,
pub page_code: String,
pub title: String,
pub visits: Vec<VisitorLogEntry>,
}
impl IntoResponse for UserPageAnalyticsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+1046 -162
View File
File diff suppressed because it is too large. Load diff
+209 -45
View File
@@ -149,20 +149,105 @@ pub async fn api_create_url(
None
};
// Dynamically resolve target user ID and content DB
let (target_user_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "urls")
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(ApiError {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
}
// Check availability
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return (
StatusCode::CONFLICT,
Json(ApiError {
error: "Short code already exists".to_string(),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"url",
"",
"reserving",
) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: format!("Failed to reserve slug: {}", e),
}),
)
.into_response();
}
}
let tags = payload.tags.unwrap_or_default();
let conn = state.content_db.lock().unwrap();
match crate::db::content::create_url_extended(
&conn,
&code,
&dest,
payload.title.as_deref(),
payload.description.as_deref(),
&tags,
payload.expires_at.as_deref(),
password_hash.as_deref(),
payload.max_access_count,
) {
let res = {
let conn = content_db.lock().unwrap();
crate::db::content::create_url_extended(
&conn,
&code,
&dest,
payload.title.as_deref(),
payload.description.as_deref(),
&tags,
payload.expires_at.as_deref(),
password_hash.as_deref(),
payload.max_access_count,
)
};
match res {
Ok(url) => {
// Activate slug
{
let system_conn = state.system_db.lock().unwrap();
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
);
}
// Increment quota
{
let users_conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
}
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log(
@@ -189,24 +274,17 @@ pub async fn api_create_url(
}
(StatusCode::CREATED, Json(url)).into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
(
StatusCode::CONFLICT,
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: "Short code already exists".to_string(),
error: e.to_string(),
}),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: e.to_string(),
}),
)
.into_response(),
}
}
@@ -434,16 +512,109 @@ pub async fn api_create_page(
}
}
let conn = state.content_db.lock().unwrap();
match create_landing_page(
&conn,
&code,
&payload.slug,
&payload.title,
&payload.html_content,
&payload.state,
) {
// Dynamically resolve target user ID and content DB
let (target_user_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "landings")
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(ApiError {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
}
// Check availability
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return (
StatusCode::CONFLICT,
Json(ApiError {
error: "Short code already exists".to_string(),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"page",
"",
"reserving",
) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: format!("Failed to reserve slug: {}", e),
}),
)
.into_response();
}
}
let res = {
let conn = content_db.lock().unwrap();
create_landing_page(
&conn,
&code,
&payload.slug,
&payload.title,
&payload.html_content,
&payload.state,
)
};
match res {
Ok(page) => {
// Activate slug
{
let system_conn = state.system_db.lock().unwrap();
let global_status = if payload.state == "published" {
"active"
} else {
"disabled"
};
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
);
}
// Increment quota
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(
&users_conn,
target_user_id,
"landings",
);
}
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log(
@@ -457,24 +628,17 @@ pub async fn api_create_page(
);
(StatusCode::CREATED, Json(page)).into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
(
StatusCode::CONFLICT,
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: "Short code already exists".to_string(),
error: e.to_string(),
}),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: e.to_string(),
}),
)
.into_response(),
}
}
+137 -13
View File
@@ -165,7 +165,53 @@ pub async fn api_bulk_url(
.into_response();
}
let mut conn = state.content_db.lock().unwrap();
// Dynamically resolve target user ID and content DB
let (target_user_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if let Some(quotas) =
crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None)
{
if quotas.current_urls + (payload.len() as i64) > quotas.max_urls {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
} else {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "User quota not found".to_string(),
}),
)
.into_response();
}
}
let mut conn = content_db.lock().unwrap();
let tx = match conn.transaction() {
Ok(t) => t,
Err(e) => {
@@ -180,6 +226,7 @@ pub async fn api_bulk_url(
};
let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in payload {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
@@ -188,6 +235,12 @@ pub async fn api_bulk_url(
} else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = tx.rollback();
// Release reserving slugs
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
@@ -198,11 +251,66 @@ pub async fn api_bulk_url(
}
}
// Reserve slug
{
let system_conn = state.system_db.lock().unwrap();
// Check availability in system.db and also check in our currently reserved slugs in this batch
let available = crate::db::users::is_slug_available(&system_conn, &code)
.unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"url",
"",
"reserving",
) {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to reserve slug '{}': {}", code, e),
}),
)
.into_response();
}
reserved_slugs.push(code.clone());
}
let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(
&system_conn,
slug,
target_user_id,
);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
@@ -229,20 +337,13 @@ pub async fn api_bulk_url(
item.max_access_count,
) {
Ok(url) => created_urls.push(url),
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
let _ = tx.rollback();
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
Err(e) => {
let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
@@ -255,6 +356,10 @@ pub async fn api_bulk_url(
}
if let Err(e) = tx.commit() {
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
@@ -264,6 +369,25 @@ pub async fn api_bulk_url(
.into_response();
}
// Activate slugs
{
let system_conn = state.system_db.lock().unwrap();
for url in &created_urls {
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
);
}
}
// Increment quota counters
{
let users_conn = state.users_db.lock().unwrap();
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
}
}
// Write Audit Log for the entire batch
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
+10 -5
View File
@@ -63,14 +63,19 @@ pub async fn resolve_page(
.into_response();
}
// 2. Get user specific database connections
let user_dbs = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
// tenant users use per-user content databases
let content_conn = if owner_user_id == 1 {
state.content_db.clone()
} else {
match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
let page_opt = {
let conn = user_dbs.content.lock().unwrap();
let conn = content_conn.lock().unwrap();
match crate::db::content::get_landing_page_by_code(&conn, &code) {
Ok(page) => page,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
+59 -71
View File
@@ -10,7 +10,6 @@ use std::net::SocketAddr;
use serde_json::json;
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
pub async fn qr_handler(
State(state): State<AppState>,
@@ -38,12 +37,12 @@ pub async fn qr_handler(
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}
// We need to look up owner_user_id and status from global_slugs
let (owner_user_id, slug_status) = {
// We need to look up owner_user_id, target_id, and status from global_slugs
let (owner_user_id, target_id, slug_status) = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
{
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;",
) {
Ok(s) => s,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
@@ -52,19 +51,25 @@ pub async fn qr_handler(
use rusqlite::OptionalExtension;
match stmt
.query_row(rusqlite::params![&file], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional()
{
Ok(Some((uid, status))) => (uid, status),
Ok(None) => (1, "active".to_string()), // fallback to admin
Ok(Some((uid, tid, status))) => (uid, tid, status),
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
}
};
if slug_status != "active" {
if slug_status == "disabled" {
return (StatusCode::GONE, "This content has been disabled").into_response();
} else if slug_status != "active" {
return (StatusCode::NOT_FOUND, "URL not found").into_response();
}
@@ -73,31 +78,16 @@ pub async fn qr_handler(
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let url_opt = {
let conn = user_dbs.content.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, &file) {
Ok(u) => u,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
}
};
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
};
let qr_scans = {
let conn = user_dbs.analytics.lock().unwrap();
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
};
let direct_clicks = {
let conn = user_dbs.analytics.lock().unwrap();
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
rusqlite::params![url.id],
"SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
rusqlite::params![target_id],
|row| row.get(0),
)
.unwrap_or(0)
@@ -113,15 +103,17 @@ pub async fn qr_handler(
let code = parts[0];
let ext = parts[1].to_lowercase();
if !crate::utils::validation::validate_redirect_code(code) {
if !crate::utils::validation::validate_redirect_code(code)
&& !crate::utils::validation::validate_page_code(code)
{
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
// We need to look up owner_user_id and status from global_slugs
let (owner_user_id, slug_status) = {
// We need to look up owner_user_id, target_type, target_id, and status from global_slugs
let (owner_user_id, target_type, target_id, slug_status) = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
.prepare("SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;")
{
Ok(s) => s,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
@@ -129,38 +121,27 @@ pub async fn qr_handler(
use rusqlite::OptionalExtension;
match stmt
.query_row(rusqlite::params![code], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
{
Ok(Some((uid, status))) => (uid, status),
Ok(None) => (1, "active".to_string()), // fallback to admin
Ok(Some(info)) => info,
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
if slug_status != "active" {
return (StatusCode::NOT_FOUND, "Url not found").into_response();
if slug_status == "disabled" {
return (StatusCode::GONE, "This content has been disabled").into_response();
} else if slug_status != "active" {
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
let user_dbs = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let url_opt = {
let conn = user_dbs.content.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, code) {
Ok(u) => u,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
};
// Construct public base URL
let proto = if state.config.cookie_secure {
"https"
@@ -178,7 +159,11 @@ pub async fn qr_handler(
.clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code);
let full_url = if target_type == "page" {
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
} else {
format!("{}/{}", base_url.trim_end_matches('/'), code)
};
// Generate QR code based on format
let (body, content_type) = if ext == "svg" {
@@ -211,22 +196,25 @@ pub async fn qr_handler(
.into_response();
};
// Log the QR access event
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
// Log the QR access event in a try-catch style
let _ = {
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
{
let analytics_conn = user_dbs.analytics.lock().unwrap();
let _ = crate::db::qr::log_qr_access(
&analytics_conn,
&url.id,
Some(ip.as_str()),
user_agent.as_deref(),
);
}
if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
if let Ok(analytics_conn) = user_dbs.analytics.lock() {
let _ = crate::db::qr::log_qr_access(
&analytics_conn,
&target_id,
Some(ip.as_str()),
user_agent.as_deref(),
);
}
}
};
Response::builder()
.header("content-type", content_type)
+32 -12
View File
@@ -24,8 +24,10 @@ pub async fn resolve_redirect(
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response {
// Basic validation of code (must be 6 hex characters or a valid custom slug)
if !crate::utils::validation::validate_redirect_code(&code) {
// Basic validation of code (must be 6 hex characters, 4 hex characters, or a valid custom slug)
if !crate::utils::validation::validate_redirect_code(&code)
&& !crate::utils::validation::validate_page_code(&code)
{
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
@@ -49,7 +51,7 @@ pub async fn resolve_redirect(
.optional()
};
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
Ok(Some(info)) => info,
Ok(None) => {
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
@@ -67,14 +69,24 @@ pub async fn resolve_redirect(
.into_response();
}
// 2. Get user specific database connections
let user_dbs = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
// If target type is page, redirect permanently to /p/slug
if target_type == "page" {
return Redirect::permanent(&format!("/p/{}", code)).into_response();
}
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
// tenant users use per-user content databases
let content_conn = if owner_user_id == 1 {
state.content_db.clone()
} else {
match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
let url_opt = {
let conn = user_dbs.content.lock().unwrap();
let conn = content_conn.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, &code) {
Ok(url) => url,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
@@ -96,7 +108,7 @@ pub async fn resolve_redirect(
if expires_at.with_timezone(&Utc) < Utc::now() {
// Mark as expired in DB asynchronously/immediately
{
let conn = user_dbs.content.lock().unwrap();
let conn = content_conn.lock().unwrap();
let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()],
@@ -131,12 +143,12 @@ pub async fn resolve_redirect(
// 6. Increment access count & retrieve preview config
let _new_access_count = {
let conn = user_dbs.content.lock().unwrap();
let conn = content_conn.lock().unwrap();
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
};
let preview_opt = {
let conn = user_dbs.content.lock().unwrap();
let conn = content_conn.lock().unwrap();
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
};
@@ -188,6 +200,14 @@ pub async fn resolve_redirect(
}
.into_response()
} else {
Redirect::temporary(&url.destination).into_response()
{
use axum::http::{header, HeaderValue};
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
resp.headers_mut().insert(
header::LOCATION,
HeaderValue::from_str(&url.destination).unwrap(),
);
resp
}
}
}
+16
View File
@@ -52,10 +52,26 @@ pub fn create_router(state: AppState) -> Router {
"/user/analytics/url/:id",
get(admin::user_url_analytics_get),
)
.route(
"/user/analytics/url/:id/export/csv",
get(admin::user_url_analytics_csv_export),
)
.route(
"/user/analytics/url/:id/export/json",
get(admin::user_url_analytics_json_export),
)
.route(
"/user/analytics/page/:id",
get(admin::user_page_analytics_get),
)
.route(
"/user/analytics/page/:id/export/csv",
get(admin::user_page_analytics_csv_export),
)
.route(
"/user/analytics/page/:id/export/json",
get(admin::user_page_analytics_json_export),
)
.route("/api-tokens", get(admin::api_tokens_get))
.route("/api-tokens/create", post(admin::api_tokens_create_post))
.route(