refactor: complete v0.8 core architecture
This commit is contained in:
1 parent
f138a645f8
commit
d7e0ac7679
98 files changed
+8413
-3044
No files matched your search
+121
-34
@@ -12,8 +12,36 @@ pub async fn url_analytics_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let url = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_url_by_id(&conn, &id) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
@@ -21,7 +49,7 @@ pub async fn url_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
|
||||
let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
let has_utm_source = schema_cols.contains("utm_source");
|
||||
@@ -184,8 +212,36 @@ pub async fn page_analytics_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let page = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &id) {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
||||
@@ -193,7 +249,7 @@ pub async fn page_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
|
||||
let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
let has_utm_source = schema_cols.contains("utm_source");
|
||||
@@ -439,6 +495,7 @@ pub async fn user_analytics_get(
|
||||
accept_language: row.get(7)?,
|
||||
country: row.get(8)?,
|
||||
status_code: row.get(9)?,
|
||||
owner_tenant_id: user.tenant_id,
|
||||
owner_user_id: Some(user.id),
|
||||
})
|
||||
})
|
||||
@@ -478,12 +535,12 @@ pub async fn user_url_analytics_get(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -493,7 +550,12 @@ pub async fn user_url_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -674,12 +736,12 @@ pub async fn user_page_analytics_get(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -689,7 +751,12 @@ pub async fn user_page_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -862,12 +929,12 @@ pub async fn user_url_analytics_csv_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -877,7 +944,12 @@ pub async fn user_url_analytics_csv_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -901,12 +973,12 @@ pub async fn user_url_analytics_json_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -916,7 +988,12 @@ pub async fn user_url_analytics_json_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -940,12 +1017,12 @@ pub async fn user_page_analytics_csv_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -955,7 +1032,12 @@ pub async fn user_page_analytics_csv_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -979,12 +1061,12 @@ pub async fn user_page_analytics_json_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -994,7 +1076,12 @@ pub async fn user_page_analytics_json_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
|
||||
+12
-52
@@ -96,25 +96,7 @@ fn load_tenant_user_by_username(
|
||||
conn: &rusqlite::Connection,
|
||||
username: &str,
|
||||
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE username = ?1;",
|
||||
[username],
|
||||
|row| {
|
||||
Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
crate::db::users::get_user_by_username(conn, username)
|
||||
}
|
||||
|
||||
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
|
||||
@@ -180,21 +162,18 @@ pub async fn login_post(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
match crate::db::users::create_admin_user(&conn, &form.username, &hash) {
|
||||
Ok(u) => {
|
||||
if let Err(e) = state.db.init_user_databases(u.id) {
|
||||
tracing::error!(
|
||||
"Failed to init user databases during admin bootstrap: {:?}",
|
||||
e
|
||||
);
|
||||
let created_user_res = {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
crate::db::users::create_admin_user(&conn, &form.username, &hash)
|
||||
};
|
||||
|
||||
match created_user_res {
|
||||
Ok(u) => {
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
@@ -385,26 +364,7 @@ pub async fn public_login_post(
|
||||
|
||||
let user_opt: Option<crate::models::TenantUser> = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE username = ?1;",
|
||||
[&form.username],
|
||||
|row| {
|
||||
Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
}
|
||||
).optional();
|
||||
|
||||
match user_res {
|
||||
match crate::db::users::get_user_by_username(&conn, &form.username) {
|
||||
Ok(Some(u)) => {
|
||||
if u.status != "active" {
|
||||
None
|
||||
|
||||
+35
-39
@@ -95,25 +95,44 @@ pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Res
|
||||
};
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let total_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_total_clicks(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let clicks_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_clicks_trend(&conn, "url", "all", 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||
.unwrap_or_default()
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let mut trend_map = std::collections::BTreeMap::new();
|
||||
@@ -123,35 +142,12 @@ pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Res
|
||||
.to_string();
|
||||
trend_map.insert(date_str, 0i64);
|
||||
}
|
||||
for (d, c) in clicks_data {
|
||||
trend_map.insert(d, c);
|
||||
}
|
||||
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||
|
||||
let countries_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let countries_chart = generate_bar_chart(&countries_data);
|
||||
|
||||
let referrers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||
|
||||
let browsers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||
let countries_chart = generate_bar_chart(&[]);
|
||||
let referrers_chart = generate_bar_chart(&[]);
|
||||
let browsers_chart = generate_bar_chart(&[]);
|
||||
|
||||
let template = crate::templates::DashboardTemplate {
|
||||
admin_username: user.username,
|
||||
|
||||
+23
-6
@@ -49,9 +49,27 @@ pub async fn health_get(
|
||||
db_reports.push(r);
|
||||
}
|
||||
|
||||
let system_db_path = state.config.data_dir.join("admin").join("system.db");
|
||||
let users_db_path = state.config.data_dir.join("admin").join("users.db");
|
||||
let admin_db_path = state.config.data_dir.join("admin").join("admin.db");
|
||||
if let Ok(r) = crate::db::sqlite::collect_health_report(
|
||||
&state.db.global_urls.lock().unwrap(),
|
||||
"global_urls",
|
||||
) {
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) = crate::db::sqlite::collect_health_report(
|
||||
&state.db.global_landing_pages.lock().unwrap(),
|
||||
"global_landing_pages",
|
||||
) {
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.db.reserved.lock().unwrap(), "reserved")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
|
||||
let system_db_path = state.db.topology.system_db();
|
||||
let users_db_path = state.db.topology.users_registry_db();
|
||||
let admin_db_path = state.db.topology.admin_db();
|
||||
|
||||
let system_db_size = format_size(
|
||||
std::fs::metadata(&system_db_path)
|
||||
@@ -69,7 +87,7 @@ pub async fn health_get(
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
let users_dir = state.config.data_dir.join("users");
|
||||
let users_dir = state.db.topology.users_dir();
|
||||
let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0));
|
||||
let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0));
|
||||
|
||||
@@ -127,8 +145,7 @@ pub async fn health_get(
|
||||
for issue in issues {
|
||||
use crate::services::registry_validator::RegistryIssueType;
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::StaleReservation
|
||||
| RegistryIssueType::TenantAdminHasIsolatedContent => {
|
||||
RegistryIssueType::StaleReservation => {
|
||||
warnings.push(format!(
|
||||
"Warning for slug {}: {}",
|
||||
issue.slug, issue.description
|
||||
|
||||
+87
-7
@@ -186,8 +186,48 @@ pub(crate) async fn perform_csv_export(
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = if target_type == "url" {
|
||||
state.db.global_urls.lock().unwrap()
|
||||
} else {
|
||||
state.db.global_landing_pages.lock().unwrap()
|
||||
};
|
||||
let table = if target_type == "url" {
|
||||
"global_urls"
|
||||
} else {
|
||||
"global_landing_pages"
|
||||
};
|
||||
match conn.query_row(
|
||||
&format!(
|
||||
"SELECT slug, owner_tenant_id FROM {} WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
table
|
||||
),
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Target not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
@@ -203,7 +243,7 @@ pub(crate) async fn perform_csv_export(
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
@@ -215,14 +255,14 @@ pub(crate) async fn perform_csv_export(
|
||||
};
|
||||
|
||||
let (has_utm_source, has_utm_campaign) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
(cols.contains("utm_source"), cols.contains("utm_campaign"))
|
||||
};
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
|
||||
let analytics_db = state.analytics_db.clone();
|
||||
let analytics_db = user_dbs.analytics.clone();
|
||||
let target_id = id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -378,8 +418,48 @@ pub(crate) async fn perform_json_export(
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = if target_type == "url" {
|
||||
state.db.global_urls.lock().unwrap()
|
||||
} else {
|
||||
state.db.global_landing_pages.lock().unwrap()
|
||||
};
|
||||
let table = if target_type == "url" {
|
||||
"global_urls"
|
||||
} else {
|
||||
"global_landing_pages"
|
||||
};
|
||||
match conn.query_row(
|
||||
&format!(
|
||||
"SELECT slug, owner_tenant_id FROM {} WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
table
|
||||
),
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Target not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
@@ -395,7 +475,7 @@ pub(crate) async fn perform_json_export(
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
@@ -411,7 +491,7 @@ pub(crate) async fn perform_json_export(
|
||||
}
|
||||
|
||||
let visits_raw = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
match get_target_visits_all_in_memory(
|
||||
&conn,
|
||||
target_type,
|
||||
|
||||
+325
-291
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::identity::TenantId;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GlobalSlugRow {
|
||||
@@ -48,28 +49,83 @@ pub async fn moderation_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let flagged_items = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \
|
||||
FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let mut flagged_items: Vec<GlobalSlugRow> = Vec::new();
|
||||
|
||||
// Query global_urls.db
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let query_res = urls_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at \
|
||||
FROM global_urls WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "url".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
if let Ok(items) = query_res {
|
||||
flagged_items.extend(items);
|
||||
}
|
||||
}
|
||||
|
||||
// Query global_landing_pages.db
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let query_res = pages_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at \
|
||||
FROM global_landing_pages WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "page".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
if let Ok(items) = query_res {
|
||||
flagged_items.extend(items);
|
||||
}
|
||||
}
|
||||
|
||||
flagged_items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
|
||||
let logs = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
@@ -139,46 +195,91 @@ pub async fn moderation_post(
|
||||
return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
|
||||
}
|
||||
|
||||
let (owner_user_id, target_type) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||
let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/moderation?error=Invalid tenant on slug").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let owner_username = {
|
||||
let (owner_user_id, owner_username) = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
|
||||
.unwrap_or(None)
|
||||
.map(|u| u.username)
|
||||
match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) {
|
||||
Ok(Some(u)) => (u.id, Some(u.username)),
|
||||
_ => (0, None),
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if action == "deleted" {
|
||||
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
// 1. Update v0.8 slug database
|
||||
if action == "deleted" {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]);
|
||||
} else {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, form.slug],
|
||||
);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, form.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync to tenant content DB if accessible
|
||||
if let Ok(tenant_dbs) =
|
||||
state.open_tenant(owner_tenant_id, crate::db::tenant::TenantOpenMode::CoreJob)
|
||||
{
|
||||
if let Ok(conn) = tenant_dbs.content.lock() {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let content_status = if action == "disabled" || action == "deleted" {
|
||||
"dead"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET status = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_status, form.slug],
|
||||
);
|
||||
} else {
|
||||
let content_state = if action == "disabled" || action == "deleted" {
|
||||
"archived"
|
||||
} else {
|
||||
"published"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE landing_pages SET state = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_state, form.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Record moderation event and audit log in system.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
|
||||
rusqlite::params![
|
||||
event_id,
|
||||
@@ -186,7 +287,7 @@ pub async fn moderation_post(
|
||||
user.username,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
target_type,
|
||||
slug_info.target_type.as_str(),
|
||||
form.slug,
|
||||
action,
|
||||
form.severity,
|
||||
@@ -231,48 +332,130 @@ pub async fn slugs_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut slugs: Vec<GlobalSlugRow> = Vec::new();
|
||||
|
||||
let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string();
|
||||
let mut values = vec![];
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(o) = query.owner {
|
||||
sql.push_str(" AND owner_user_id = ?");
|
||||
values.push(rusqlite::types::Value::Integer(o));
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
// Query global_urls.db
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_urls WHERE 1=1".to_string();
|
||||
let mut values = vec![];
|
||||
|
||||
let slugs = {
|
||||
let mut stmt = system_conn.prepare(&sql).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
|
||||
let items_res = urls_conn.prepare(&sql).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "url".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
|
||||
if let Ok(items) = items_res {
|
||||
for r in items {
|
||||
if let Some(o) = query.owner {
|
||||
if r.owner_user_id != o {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
slugs.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query global_landing_pages.db
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_landing_pages WHERE 1=1".to_string();
|
||||
let mut values = vec![];
|
||||
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
|
||||
let items_res = pages_conn.prepare(&sql).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "page".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
|
||||
if let Ok(items) = items_res {
|
||||
for r in items {
|
||||
if let Some(o) = query.owner {
|
||||
if r.owner_user_id != o {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
slugs.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slugs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
let history = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \
|
||||
FROM slug_history ORDER BY timestamp DESC LIMIT 50;"
|
||||
@@ -332,198 +515,19 @@ pub async fn slugs_transfer_post(
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let user_exists = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[form.new_owner_user_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
let req = crate::services::slug_transfer::SlugTransferRequest {
|
||||
slug: form.slug.clone(),
|
||||
new_owner_user_id: form.new_owner_user_id,
|
||||
};
|
||||
|
||||
if !user_exists {
|
||||
return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response();
|
||||
match crate::services::slug_transfer::transfer_slug(&state, &req, &user.username) {
|
||||
Ok(_) => Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||
form.slug, form.new_owner_user_id
|
||||
))
|
||||
.into_response(),
|
||||
Err(e) => Redirect::to(&format!("/admin/slugs?error={}", e.message())).into_response(),
|
||||
}
|
||||
|
||||
let (old_owner, target_type, mut new_target_id) =
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String, String)> = conn.query_row(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
).optional().unwrap_or(None);
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if old_owner == form.new_owner_user_id {
|
||||
return Redirect::to("/admin/slugs?error=Slug is already owned by this user")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let old_dbs = match state.get_user_dbs(old_owner) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load current owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(form.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load new owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old URL: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_url = match 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,
|
||||
) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy URL record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_url.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug)
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old page: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_page = match crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy page record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_page.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug],
|
||||
);
|
||||
|
||||
let _ = 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![form.slug, old_owner, form.new_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!(
|
||||
"Transferred from {} to {}",
|
||||
old_owner, form.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||
form.slug, form.new_owner_user_id
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -553,17 +557,30 @@ pub async fn slugs_status_post(
|
||||
return Redirect::to("/admin/slugs?error=Invalid status").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![status, now, form.slug],
|
||||
);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![status, now, form.slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"SLUG_STATUS_UPDATE",
|
||||
"slug",
|
||||
@@ -600,29 +617,46 @@ pub async fn slugs_delete_post(
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
let old_owner_user_id = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if let Ok(tid) = TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let old_owner_user_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
|
||||
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
|
||||
let _ = conn.execute(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![form.slug, old_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"SLUG_RELEASE",
|
||||
"slug",
|
||||
|
||||
+130
-167
@@ -118,22 +118,26 @@ pub async fn user_pages_create(
|
||||
}
|
||||
}
|
||||
|
||||
let owner_tid = user
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/user/pages?error=Short code/slug already exists")
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_landing_page_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
&owner_tid,
|
||||
) {
|
||||
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
return Redirect::to(&format!(
|
||||
"/user/pages?error=Short code/slug unavailable: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,16 +156,8 @@ pub async fn user_pages_create(
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if form.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],
|
||||
);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
@@ -181,8 +177,8 @@ pub async fn user_pages_create(
|
||||
Redirect::to("/user/pages").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &owner_tid);
|
||||
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -221,11 +217,13 @@ pub async fn user_pages_delete(
|
||||
);
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&page.code,
|
||||
user.id,
|
||||
);
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&page.code],
|
||||
);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
@@ -265,8 +263,14 @@ pub async fn pages_get(
|
||||
};
|
||||
|
||||
let (pages, total_pages, page, visible_pages) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let total_records = get_landing_page_count(&conn).unwrap_or(0);
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let total_records: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
@@ -278,7 +282,58 @@ pub async fn pages_get(
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status FROM global_landing_pages WHERE status != 'retired' ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![PAGE_SIZE as i64, offset as i64], |row| {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_tid_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
let updated_at: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
Ok((
|
||||
slug,
|
||||
owner_tid_str,
|
||||
target_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut pages = Vec::new();
|
||||
for item in rows.flatten() {
|
||||
let (slug, owner_tid_str, target_id, created_at, updated_at, status) = item;
|
||||
let mut resolved_page = None;
|
||||
if let Ok(tid) = owner_tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob)
|
||||
{
|
||||
let u_conn = user_dbs.content.lock().unwrap();
|
||||
if let Ok(Some(p)) =
|
||||
crate::db::content::get_landing_page_by_id(&u_conn, &target_id)
|
||||
{
|
||||
resolved_page = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = resolved_page {
|
||||
pages.push(p);
|
||||
} else {
|
||||
pages.push(crate::models::LandingPage {
|
||||
id: target_id,
|
||||
code: slug.clone(),
|
||||
slug,
|
||||
title: String::new(),
|
||||
html_content: String::new(),
|
||||
state: status,
|
||||
created_at,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
@@ -302,7 +357,8 @@ pub async fn pages_get(
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CreatePageForm {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
@@ -317,126 +373,16 @@ pub struct CreatePageForm {
|
||||
pub async fn pages_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreatePageForm>,
|
||||
_headers: HeaderMap,
|
||||
_connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(_form): Form<CreatePageForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
let (_user, _session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||
|
||||
// Custom Slug takes priority if provided
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(2);
|
||||
} else {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to(
|
||||
"/admin/pages?error=Custom code must be exactly 4 hex characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let clean_slug = form.slug.trim().to_lowercase();
|
||||
if clean_slug.is_empty() {
|
||||
return Redirect::to("/admin/pages?error=Slug is required").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/admin/pages?error=Short code already exists").into_response();
|
||||
}
|
||||
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||
if let Err(e) =
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving")
|
||||
{
|
||||
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&clean_slug,
|
||||
&form.title,
|
||||
&form.html_content,
|
||||
&form.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if form.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],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(
|
||||
&users_conn,
|
||||
admin_user_id,
|
||||
"landings",
|
||||
);
|
||||
}
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Redirect::to("/admin/pages?error=Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account").into_response()
|
||||
}
|
||||
|
||||
// POST /admin/pages/delete/:id
|
||||
@@ -460,25 +406,42 @@ pub async fn pages_delete(
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
// Look up owner tenant in global_landing_pages
|
||||
let owner_info = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
).ok()
|
||||
};
|
||||
|
||||
if let Some((slug, tid_str)) = owner_info {
|
||||
if let Ok(tid) = tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = delete_landing_page(&conn, &id);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_landing_pages SET status = 'retired', updated_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
+220
-119
@@ -106,11 +106,13 @@ pub async fn user_download_backup(
|
||||
};
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user.id.to_string());
|
||||
let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
}) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let res = {
|
||||
@@ -224,11 +226,15 @@ pub async fn user_restore_backup_post(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user.id.to_string());
|
||||
let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
}) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return Redirect::to("/user/settings?error=Invalid user directory").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let temp_unpack_dir =
|
||||
std::env::temp_dir().join(format!("bzod_restore_unpack_{}", uuid::Uuid::new_v4()));
|
||||
@@ -241,42 +247,124 @@ pub async fn user_restore_backup_post(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let restore_res = {
|
||||
let file = match File::open(&temp_file_path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
let _ = std::fs::remove_dir_all(&temp_unpack_dir);
|
||||
return Redirect::to(&format!(
|
||||
"/user/settings?error=Failed to open upload: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let restore_res: Result<(), Box<dyn std::error::Error>> = (|| {
|
||||
let file = File::open(&temp_file_path)?;
|
||||
let tar_gz = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(tar_gz);
|
||||
if let Err(e) = archive.unpack(&temp_unpack_dir) {
|
||||
Err(Box::new(e) as Box<dyn std::error::Error>)
|
||||
} else {
|
||||
// Check for collision using temp content.db
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let temp_content_db = temp_unpack_dir.join("content.db");
|
||||
if temp_content_db.exists() {
|
||||
if let Err(e) = crate::db::users::register_restored_user_slugs(
|
||||
&system_conn,
|
||||
user.id,
|
||||
&temp_content_db,
|
||||
) {
|
||||
Err(e)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
Err("Backup is missing content.db".into())
|
||||
archive.unpack(&temp_unpack_dir)?;
|
||||
|
||||
let target_tenant_id = user
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
let temp_content_db = temp_unpack_dir.join("content.db");
|
||||
if !temp_content_db.exists() {
|
||||
return Err("Backup is missing content.db".into());
|
||||
}
|
||||
|
||||
let content_conn = rusqlite::Connection::open(&temp_content_db)?;
|
||||
|
||||
let mut urls = Vec::new();
|
||||
if let Ok(mut stmt) = content_conn.prepare("SELECT code, id, created_at, status FROM urls;")
|
||||
{
|
||||
if let Ok(rows) = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
}) {
|
||||
urls = rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut pages = Vec::new();
|
||||
if let Ok(mut stmt) =
|
||||
content_conn.prepare("SELECT code, id, created_at, state FROM landing_pages;")
|
||||
{
|
||||
if let Ok(rows) = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
}) {
|
||||
pages = rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
|
||||
for (slug, _, _, _) in &urls {
|
||||
if let Ok(Some(existing_owner)) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
{
|
||||
if existing_owner != target_tenant_id.as_str() {
|
||||
return Err(format!("Slug collision on URL /{}", slug).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (slug, _, _, _) in &pages {
|
||||
if let Ok(Some(existing_owner)) = pages_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
{
|
||||
if existing_owner != target_tenant_id.as_str() {
|
||||
return Err(format!("Slug collision on Landing Page /{}", slug).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE owner_tenant_id = ?1;",
|
||||
[target_tenant_id.as_str()],
|
||||
);
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE owner_tenant_id = ?1;",
|
||||
[target_tenant_id.as_str()],
|
||||
);
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for (slug, target_id, created_at, status) in urls {
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let _ = urls_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![slug, target_tenant_id.as_str(), target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
|
||||
for (slug, target_id, created_at, state) in pages {
|
||||
let global_status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = pages_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![slug, target_tenant_id.as_str(), target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
|
||||
@@ -316,7 +404,9 @@ pub async fn user_restore_backup_post(
|
||||
}
|
||||
|
||||
let mut pool = state.user_dbs.lock().unwrap();
|
||||
pool.remove(&user.id);
|
||||
if let Ok(loc) = crate::db::tenant::location_for_user(&user) {
|
||||
pool.remove(&loc.cache_key());
|
||||
}
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
@@ -678,8 +768,43 @@ pub async fn bulk_qr_export_post(
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let urls = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default()
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT slug, target_id FROM global_urls WHERE status = 'active' LIMIT 500;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return Redirect::to("/admin/settings?error=Database error").into_response(),
|
||||
};
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let code: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
Ok(crate::models::Url {
|
||||
id: target_id,
|
||||
code: code.clone(),
|
||||
destination: format!(
|
||||
"{}/{}",
|
||||
state.config.base_url.clone().unwrap_or_default(),
|
||||
code
|
||||
),
|
||||
title: None,
|
||||
description: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
status: "active".to_string(),
|
||||
tags: vec![],
|
||||
expires_at: None,
|
||||
password_hash: None,
|
||||
max_access_count: None,
|
||||
access_count: 0,
|
||||
expired: false,
|
||||
last_latency_ms: None,
|
||||
last_status: None,
|
||||
})
|
||||
});
|
||||
match rows {
|
||||
Ok(r) => r.filter_map(|x| x.ok()).collect(),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
};
|
||||
|
||||
if urls.is_empty() {
|
||||
@@ -761,10 +886,7 @@ pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Respon
|
||||
let uptime_duration = state.start_time.elapsed();
|
||||
let uptime = crate::utils::format_duration(uptime_duration);
|
||||
|
||||
let urls = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default()
|
||||
};
|
||||
let urls = vec![];
|
||||
|
||||
let template = crate::templates::StatusTemplate {
|
||||
admin_username: user.username,
|
||||
@@ -911,93 +1033,72 @@ pub async fn restore_backup_post(
|
||||
let restore_res = {
|
||||
// Temporarily suspend access to active SQLite connections
|
||||
let mut admin_conn = state.admin_db.lock().unwrap();
|
||||
let mut content_conn = state.content_db.lock().unwrap();
|
||||
let mut analytics_conn = state.analytics_db.lock().unwrap();
|
||||
let mut system_conn = state.system_db.lock().unwrap();
|
||||
let mut users_conn = state.users_db.lock().unwrap();
|
||||
let mut urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let mut pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let mut reserved_conn = state.db.reserved.lock().unwrap();
|
||||
|
||||
// 1. Close current connections by replacing them with dummy in-memory DBs
|
||||
*admin_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*content_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*analytics_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*system_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*admin_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*system_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*users_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*urls_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*pages_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*reserved_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
|
||||
// 2. Perform restore unpacking/validation
|
||||
// perform_restore() handles legacy layout normalization internally
|
||||
// before validation, so no post-restore normalization is needed.
|
||||
// 2. Clear tenant pool cache
|
||||
if let Ok(mut pool) = state.user_dbs.lock() {
|
||||
pool.clear();
|
||||
}
|
||||
|
||||
// 3. Perform restore unpacking/validation
|
||||
let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir);
|
||||
|
||||
// 3. Reinitialize database connections using correct multi-tenant paths
|
||||
let paths =
|
||||
crate::services::backup_layout::RestoredDbPaths::from_data_dir(&state.config.data_dir);
|
||||
let new_admin = rusqlite::Connection::open(&paths.admin);
|
||||
let new_system = rusqlite::Connection::open(&paths.system);
|
||||
let new_users = rusqlite::Connection::open(&paths.users);
|
||||
let new_content = rusqlite::Connection::open(&paths.content);
|
||||
let new_analytics = rusqlite::Connection::open(&paths.analytics);
|
||||
// 4. Reinitialize Core database connections
|
||||
let topology = crate::db::topology::Topology::new(&state.config.data_dir);
|
||||
let new_admin = rusqlite::Connection::open(topology.admin_db());
|
||||
let new_system = rusqlite::Connection::open(topology.system_db());
|
||||
let new_users = rusqlite::Connection::open(topology.users_registry_db());
|
||||
let new_urls = rusqlite::Connection::open(topology.global_urls_db());
|
||||
let new_pages = rusqlite::Connection::open(topology.global_landing_pages_db());
|
||||
let new_reserved = rusqlite::Connection::open(topology.reserved_db());
|
||||
|
||||
match (new_admin, new_content, new_analytics, new_system, new_users) {
|
||||
(Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => {
|
||||
match (
|
||||
new_admin,
|
||||
new_system,
|
||||
new_users,
|
||||
new_urls,
|
||||
new_pages,
|
||||
new_reserved,
|
||||
) {
|
||||
(Ok(adm), Ok(sys), Ok(usr), Ok(urls), Ok(pages), Ok(resv)) => {
|
||||
let _ = crate::db::sqlite::enable_wal(&adm, "admin");
|
||||
let _ = crate::db::sqlite::enable_wal(&cnt, "content");
|
||||
let _ = crate::db::sqlite::enable_wal(&any, "analytics");
|
||||
let _ = crate::db::sqlite::enable_wal(&sys, "system");
|
||||
let _ = crate::db::sqlite::enable_wal(&usr, "users");
|
||||
let _ = crate::db::sqlite::enable_wal(&urls, "global_urls");
|
||||
let _ = crate::db::sqlite::enable_wal(&pages, "global_landing_pages");
|
||||
let _ = crate::db::sqlite::enable_wal(&resv, "reserved");
|
||||
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&adm, "admin");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&cnt, "content");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&any, "analytics");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&usr, "users");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&urls, "global_urls");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&pages, "global_landing_pages");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&resv, "reserved");
|
||||
|
||||
*admin_conn = adm;
|
||||
*content_conn = cnt;
|
||||
*analytics_conn = any;
|
||||
*system_conn = sys;
|
||||
match state.db.users.lock() {
|
||||
Ok(mut u) => *u = usr,
|
||||
Err(_) => {
|
||||
return Redirect::to(
|
||||
"/admin/settings?error=Failed to reopen restored databases",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
*users_conn = usr;
|
||||
*urls_conn = urls;
|
||||
*pages_conn = pages;
|
||||
*reserved_conn = resv;
|
||||
}
|
||||
_ => {
|
||||
return Redirect::to("/admin/settings?error=Failed to reopen restored databases")
|
||||
|
||||
+144
-213
@@ -153,21 +153,26 @@ pub async fn user_urls_create(
|
||||
}
|
||||
}
|
||||
|
||||
let owner_tid = user
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/user/urls?error=Short code/slug already exists").into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_url_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
&owner_tid,
|
||||
) {
|
||||
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
return Redirect::to(&format!(
|
||||
"/user/urls?error=Short code/slug unavailable: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +186,8 @@ pub async fn user_urls_create(
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
@@ -192,7 +199,11 @@ pub async fn user_urls_create(
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(),
|
||||
Err(_) => {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to("/user/urls?error=Hashing error").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,7 +213,9 @@ pub async fn user_urls_create(
|
||||
match form.max_access_count.trim().parse::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/user/urls?error=Invalid max access count").into_response()
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to("/user/urls?error=Invalid max access count").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -243,11 +256,8 @@ pub async fn user_urls_create(
|
||||
match res {
|
||||
Ok(url) => {
|
||||
{
|
||||
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],
|
||||
);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
@@ -266,8 +276,8 @@ pub async fn user_urls_create(
|
||||
Redirect::to("/user/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -306,11 +316,11 @@ pub async fn user_urls_delete(
|
||||
);
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&url.code,
|
||||
user.id,
|
||||
);
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn
|
||||
.execute("DELETE FROM global_urls WHERE slug = ?1;", [&url.code]);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
@@ -351,12 +361,14 @@ pub async fn urls_get(
|
||||
};
|
||||
|
||||
let (urls, total_pages, page, visible_pages) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let total_records = if let Some(tag_str) = query.tag.as_deref() {
|
||||
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
|
||||
} else {
|
||||
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
|
||||
};
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total_records: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
@@ -368,8 +380,64 @@ pub async fn urls_get(
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
|
||||
.unwrap_or_default();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status FROM global_urls WHERE status != 'retired' ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![PAGE_SIZE as i64, offset as i64], |row| {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_tid_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
let updated_at: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
Ok((
|
||||
slug,
|
||||
owner_tid_str,
|
||||
target_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut urls = Vec::new();
|
||||
for item in rows.flatten() {
|
||||
let (slug, owner_tid_str, target_id, created_at, updated_at, status) = item;
|
||||
let mut resolved_url = None;
|
||||
if let Ok(tid) = owner_tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob)
|
||||
{
|
||||
let u_conn = user_dbs.content.lock().unwrap();
|
||||
if let Ok(Some(u)) = crate::db::content::get_url_by_id(&u_conn, &target_id) {
|
||||
resolved_url = Some(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(u) = resolved_url {
|
||||
urls.push(u);
|
||||
} else {
|
||||
urls.push(crate::models::Url {
|
||||
id: target_id,
|
||||
code: slug,
|
||||
destination: String::new(),
|
||||
title: None,
|
||||
description: None,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
tags: vec![],
|
||||
expires_at: None,
|
||||
password_hash: None,
|
||||
max_access_count: None,
|
||||
access_count: 0,
|
||||
expired: false,
|
||||
last_latency_ms: None,
|
||||
last_status: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
@@ -406,7 +474,8 @@ pub async fn urls_get(
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CreateUrlForm {
|
||||
pub destination: String,
|
||||
pub code: String,
|
||||
@@ -427,170 +496,16 @@ pub struct CreateUrlForm {
|
||||
pub async fn urls_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUrlForm>,
|
||||
_headers: HeaderMap,
|
||||
_connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(_form): Form<CreateUrlForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
let (_user, _session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||
|
||||
// Custom Slug takes priority if provided
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.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 Redirect::to(
|
||||
"/admin/urls?error=Custom code must be exactly 6 hex characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let dest = match crate::services::urls::prepare_destination(
|
||||
&form.destination,
|
||||
crate::services::urls::UtmParams {
|
||||
source: Some(&form.utm_source),
|
||||
medium: Some(&form.utm_medium),
|
||||
campaign: Some(&form.utm_campaign),
|
||||
},
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
|
||||
|
||||
let password_hash_opt = if form.password.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match form.max_access_count.trim().parse::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tags_list: Vec<String> = form
|
||||
.tags
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
let title_opt = if form.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.title.trim())
|
||||
};
|
||||
let desc_opt = if form.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.description.trim())
|
||||
};
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false)
|
||||
{
|
||||
return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/admin/urls?error=Short code/slug already exists")
|
||||
.into_response();
|
||||
}
|
||||
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||
if let Err(e) =
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")
|
||||
{
|
||||
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
title_opt,
|
||||
desc_opt,
|
||||
&tags_list,
|
||||
expires_at_opt.as_deref(),
|
||||
password_hash_opt.as_deref(),
|
||||
max_access_count_opt,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
{
|
||||
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],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls");
|
||||
}
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Redirect::to("/admin/urls?error=Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account").into_response()
|
||||
}
|
||||
|
||||
// POST /admin/urls/delete/:id
|
||||
@@ -614,26 +529,42 @@ pub async fn urls_delete(
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
// Look up owner tenant in global_urls
|
||||
let owner_info = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
).ok()
|
||||
};
|
||||
|
||||
if let Some((slug, tid_str)) = owner_info {
|
||||
if let Ok(tid) = tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = delete_url(&conn, &id);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||
}
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_urls SET status = 'retired', updated_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
+14
-44
@@ -133,12 +133,14 @@ pub async fn users_create_post(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to initialize user databases: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
if new_user.account_type == "standard" {
|
||||
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to initialize user databases: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
@@ -481,25 +483,9 @@ pub async fn user_detail_get(
|
||||
|
||||
let target_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let u_res = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
);
|
||||
match u_res {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
match crate::db::users::get_user_by_id(&conn, id) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -576,25 +562,9 @@ pub async fn user_edit_get(
|
||||
|
||||
let target_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let u_res = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
);
|
||||
match u_res {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
match crate::db::users::get_user_by_id(&conn, id) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+253
-117
@@ -12,12 +12,10 @@ use crate::auth::ApiUser;
|
||||
use crate::db::admin::write_audit_log;
|
||||
use crate::db::analytics::{
|
||||
get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw,
|
||||
get_total_clicks, get_total_page_views,
|
||||
};
|
||||
use crate::db::content::{
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id,
|
||||
get_landing_page_count, get_url_by_id, get_url_counts, list_landing_pages, list_urls,
|
||||
update_landing_page, update_url,
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, get_url_by_id,
|
||||
list_landing_pages, list_urls, update_landing_page, update_url,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -73,6 +71,30 @@ pub struct ApiError {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn get_api_tenant_dbs(state: &AppState, user: &ApiUser) -> Result<crate::state::UserDbs, Response> {
|
||||
match user.0 {
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
state.get_user_dbs(u.id).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
}
|
||||
crate::models::ApiActor::Admin(_) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot access application content directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- URL Endpoints ---
|
||||
|
||||
// POST /api/v1/urls
|
||||
@@ -138,9 +160,17 @@ 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()),
|
||||
// Dynamically resolve target user ID, tenant ID, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
@@ -154,7 +184,10 @@ pub async fn api_create_url(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
let tid = u
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
(u.id, tid, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,30 +207,22 @@ pub async fn api_create_url(
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
// Check availability and reserve in v0.8 slug registry
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_url_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
&target_tenant_id,
|
||||
) {
|
||||
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),
|
||||
error: format!("Short code unavailable: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -222,13 +247,10 @@ pub async fn api_create_url(
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
// Activate slug
|
||||
// Activate slug in v0.8 global_urls.db
|
||||
{
|
||||
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],
|
||||
);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
@@ -264,8 +286,8 @@ pub async fn api_create_url(
|
||||
(StatusCode::CREATED, Json(url)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &target_tenant_id);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
@@ -287,13 +309,17 @@ pub struct ListQuery {
|
||||
|
||||
pub async fn api_list_urls(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Response {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match list_urls(&conn, limit, offset, query.tag.as_deref()) {
|
||||
Ok(urls) => Json(urls).into_response(),
|
||||
Err(e) => (
|
||||
@@ -309,10 +335,14 @@ pub async fn api_list_urls(
|
||||
// GET /api/v1/urls/:uuid
|
||||
pub async fn api_get_url(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match get_url_by_id(&conn, &uuid) {
|
||||
Ok(Some(url)) => Json(url).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -351,7 +381,11 @@ pub async fn api_update_url(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match update_url(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -438,9 +472,21 @@ pub async fn api_delete_url(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
let code_opt = match get_url_by_id(&conn, &uuid) {
|
||||
Ok(Some(u)) => Some(u.code),
|
||||
_ => None,
|
||||
};
|
||||
match delete_url(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
if let Some(code) = code_opt {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&code]);
|
||||
}
|
||||
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(
|
||||
@@ -510,9 +556,17 @@ pub async fn api_create_page(
|
||||
}
|
||||
}
|
||||
|
||||
// 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()),
|
||||
// Dynamically resolve target user ID, tenant ID, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot create application landing pages directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
@@ -526,7 +580,10 @@ pub async fn api_create_page(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
let tid = u
|
||||
.tenant_id
|
||||
.unwrap_or_else(crate::identity::TenantId::generate);
|
||||
(u.id, tid, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -546,30 +603,22 @@ pub async fn api_create_page(
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
// Check availability and reserve in v0.8 slug registry
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_landing_page_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
&target_tenant_id,
|
||||
) {
|
||||
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),
|
||||
error: format!("Short code unavailable: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -590,18 +639,10 @@ pub async fn api_create_page(
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
// Activate slug
|
||||
// Activate slug in v0.8 global_landing_pages.db
|
||||
{
|
||||
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],
|
||||
);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
@@ -627,8 +668,9 @@ pub async fn api_create_page(
|
||||
(StatusCode::CREATED, Json(page)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &target_tenant_id);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
@@ -643,13 +685,17 @@ pub async fn api_create_page(
|
||||
// GET /api/v1/pages
|
||||
pub async fn api_list_pages(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Response {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match list_landing_pages(&conn, limit, offset) {
|
||||
Ok(pages) => Json(pages).into_response(),
|
||||
Err(e) => (
|
||||
@@ -665,10 +711,14 @@ pub async fn api_list_pages(
|
||||
// GET /api/v1/pages/:uuid
|
||||
pub async fn api_get_page(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &uuid) {
|
||||
Ok(Some(page)) => Json(page).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -697,7 +747,11 @@ pub async fn api_update_page(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<UpdatePageRequest>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match update_landing_page(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -745,9 +799,22 @@ pub async fn api_delete_page(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
let code_opt = match get_landing_page_by_id(&conn, &uuid) {
|
||||
Ok(Some(p)) => Some(p.code),
|
||||
_ => None,
|
||||
};
|
||||
match delete_landing_page(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
if let Some(code) = code_opt {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn
|
||||
.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&code]);
|
||||
}
|
||||
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(
|
||||
@@ -797,20 +864,44 @@ pub async fn api_overall_stats(State(state): State<AppState>, user: ApiUser) ->
|
||||
}
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
let (total_clicks, total_page_views) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
get_total_clicks(&conn).unwrap_or(0),
|
||||
get_total_page_views(&conn).unwrap_or(0),
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let total_clicks = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
let total_page_views = 0i64;
|
||||
|
||||
Json(OverallStatsResponse {
|
||||
total_urls,
|
||||
@@ -835,12 +926,17 @@ pub struct DetailStatsResponse {
|
||||
// GET /api/v1/stats/url/:uuid
|
||||
pub async fn api_url_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -852,7 +948,7 @@ pub async fn api_url_stats(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let clicks = get_clicks_trend(&conn, "url", &uuid, 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30))
|
||||
.unwrap_or_default();
|
||||
@@ -879,12 +975,17 @@ pub async fn api_url_stats(
|
||||
// GET /api/v1/stats/page/:uuid
|
||||
pub async fn api_page_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify Page exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_landing_page_by_id(&conn, &uuid)
|
||||
.unwrap_or(None)
|
||||
.is_none()
|
||||
@@ -899,7 +1000,7 @@ pub async fn api_page_stats(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let clicks = get_clicks_trend(&conn, "page", &uuid, 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30))
|
||||
.unwrap_or_default();
|
||||
@@ -935,11 +1036,16 @@ pub struct QrStatsResponse {
|
||||
// GET /api/v1/qr/:code
|
||||
pub async fn api_get_qr_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(code): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
crate::db::content::get_url_by_code(&conn, &code).unwrap_or(None)
|
||||
};
|
||||
|
||||
@@ -957,7 +1063,7 @@ pub async fn api_get_qr_stats(
|
||||
};
|
||||
|
||||
let (scan_count, scans) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let count = crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0);
|
||||
let scans_list = crate::db::qr::get_qr_stats_for_url(&conn, &url.id).unwrap_or_default();
|
||||
(count, scans_list)
|
||||
@@ -1030,9 +1136,14 @@ pub async fn api_set_preview(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<SetPreviewRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1044,7 +1155,7 @@ pub async fn api_set_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::upsert_preview(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -1081,12 +1192,17 @@ pub async fn api_set_preview(
|
||||
// GET /api/v1/urls/:uuid/preview
|
||||
pub async fn api_get_preview(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1098,7 +1214,7 @@ pub async fn api_get_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::get_preview(&conn, &uuid) {
|
||||
Ok(Some(preview)) => Json(preview).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -1124,9 +1240,14 @@ pub async fn api_delete_preview(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1138,7 +1259,7 @@ pub async fn api_delete_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::delete_preview(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1186,9 +1307,14 @@ pub async fn api_set_password(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<SetPasswordRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1213,7 +1339,7 @@ pub async fn api_set_password(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::content::set_url_password(&conn, &uuid, &hash) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1257,9 +1383,14 @@ pub async fn api_remove_password(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1271,7 +1402,7 @@ pub async fn api_remove_password(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::content::remove_url_password(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1321,9 +1452,14 @@ pub async fn api_create_qr(
|
||||
user: ApiUser,
|
||||
Json(payload): Json<CreateQrRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists in content.db
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &payload.url_id)
|
||||
.unwrap_or(None)
|
||||
.is_none()
|
||||
@@ -1339,7 +1475,7 @@ pub async fn api_create_qr(
|
||||
}
|
||||
|
||||
let style = payload.style.unwrap_or_else(|| "default".to_string());
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::qr::upsert_qr_code(&conn, &payload.url_id, &style) {
|
||||
Ok(_) => {
|
||||
// Write Audit Event
|
||||
|
||||
+56
-6
@@ -67,9 +67,36 @@ pub async fn api_bulk_qr(
|
||||
}
|
||||
|
||||
// Retrieve URLs from database
|
||||
let content_db = match user.0 {
|
||||
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();
|
||||
}
|
||||
};
|
||||
user_dbs.content.clone()
|
||||
}
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Admin is a platform operator and cannot export application URLs without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut urls = Vec::new();
|
||||
{
|
||||
let conn = match lock_db(&state.content_db, "content_db") {
|
||||
let conn = match lock_db(&content_db, "content_db") {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (
|
||||
@@ -186,10 +213,30 @@ pub async fn api_bulk_url(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 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()),
|
||||
// Dynamically resolve target user ID, TenantId, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let tenant_id = match u.tenant_id {
|
||||
Some(tid) => tid,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "User has no tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
@@ -202,7 +249,7 @@ pub async fn api_bulk_url(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
(u.id, tenant_id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,9 +273,12 @@ pub async fn api_bulk_url(
|
||||
|
||||
let created_urls = match create_urls_bulk(
|
||||
&content_db,
|
||||
&state.system_db,
|
||||
&state.db.reserved,
|
||||
&state.db.global_urls,
|
||||
&state.db.global_landing_pages,
|
||||
&state.users_db,
|
||||
target_user_id,
|
||||
target_tenant_id,
|
||||
items,
|
||||
) {
|
||||
Ok(urls) => urls,
|
||||
|
||||
+137
-273
@@ -4,11 +4,11 @@ use axum::{
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::ApiUser;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -354,40 +354,68 @@ pub fn delete_user_resources(
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Transactional clean up on system.db (deleting their global slug mappings)
|
||||
{
|
||||
let mut system_conn = state.system_db.lock().unwrap();
|
||||
let tx = system_conn.transaction().map_err(|e| e.to_string())?;
|
||||
// 1. Transactional clean up on v0.8 slug databases
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if let Some(ref tid) = user_details.tenant_id {
|
||||
let tid_str = tid.to_string();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
|
||||
let slugs: Vec<String> = {
|
||||
let mut stmt = tx
|
||||
.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([target_id], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
// Get all URL slugs owned by tenant
|
||||
let url_slugs: Vec<String> = if let Ok(mut stmt) =
|
||||
urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")
|
||||
{
|
||||
stmt.query_map([&tid_str], |row| row.get::<_, String>(0))
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for slug in slugs {
|
||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
||||
let _ = tx.execute(
|
||||
for slug in url_slugs {
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&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, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, target_id, now, admin_username],
|
||||
);
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| format!("Failed to release slugs: {}", e))?;
|
||||
// Get all page slugs owned by tenant
|
||||
let page_slugs: Vec<String> = if let Ok(mut stmt) =
|
||||
pages_conn.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")
|
||||
{
|
||||
stmt.query_map([&tid_str], |row| row.get::<_, String>(0))
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for slug in page_slugs {
|
||||
let _ =
|
||||
pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&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, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, target_id, now, admin_username],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_id.to_string());
|
||||
let user_dir = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::get_user_by_id(&conn, target_id) {
|
||||
Ok(Some(u)) => crate::db::tenant::location_for_user(&u)
|
||||
.and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
_ => return Err("User not found".into()),
|
||||
}
|
||||
};
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
}
|
||||
@@ -444,231 +472,23 @@ pub async fn admin_transfer_slug(
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
// 1. Check if the slug exists and get details
|
||||
let (old_owner_user_id, target_type, _target_id) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
let row_opt = stmt
|
||||
.query_row([&payload.slug], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional();
|
||||
|
||||
match row_opt {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
let req = crate::services::slug_transfer::SlugTransferRequest {
|
||||
slug: payload.slug,
|
||||
new_owner_user_id: payload.new_owner_user_id,
|
||||
};
|
||||
|
||||
if old_owner_user_id == payload.new_owner_user_id {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner must be different from the current owner",
|
||||
)
|
||||
.into_response();
|
||||
match crate::services::slug_transfer::transfer_slug(&state, &req, &admin.username) {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(crate::services::slug_transfer::TransferError::NotFound(m)) => {
|
||||
(StatusCode::NOT_FOUND, m.to_string()).into_response()
|
||||
}
|
||||
Err(crate::services::slug_transfer::TransferError::BadRequest(m)) => {
|
||||
(StatusCode::BAD_REQUEST, m).into_response()
|
||||
}
|
||||
Err(crate::services::slug_transfer::TransferError::Internal(m)) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, m).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch destination databases
|
||||
let old_dbs = match state.get_user_dbs(old_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load current owner's database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load new owner's database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Perform transfer: copy record from old owner's content.db to new owner's content.db
|
||||
let mut new_target_id = String::new();
|
||||
let transfer_success = {
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
// Get URL record
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check new owner quotas
|
||||
let new_users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into new owner database
|
||||
let ins_res = 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,
|
||||
);
|
||||
|
||||
match ins_res {
|
||||
Ok(new_url) => {
|
||||
new_target_id = new_url.id;
|
||||
// Delete from old owner database
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to copy URL to new owner: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
// Get page record
|
||||
let page_opt =
|
||||
match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check new owner quotas
|
||||
let new_users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into new owner database
|
||||
let ins_res = crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
);
|
||||
|
||||
match ins_res {
|
||||
Ok(new_page) => {
|
||||
new_target_id = new_page.id;
|
||||
// Delete from old owner database
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to copy Page to new owner: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !transfer_success {
|
||||
return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response();
|
||||
}
|
||||
|
||||
// 4. Update system global_slugs, slug_history and adjust quotas
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
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![payload.new_owner_user_id, new_target_id, now, payload.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![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username],
|
||||
);
|
||||
|
||||
// Adjust quotas
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
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,
|
||||
payload.new_owner_user_id,
|
||||
field,
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&payload.slug,
|
||||
Some(&format!(
|
||||
"From owner {} to owner {}",
|
||||
old_owner_user_id, payload.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
// --- Admin: Content Moderation ---
|
||||
@@ -689,44 +509,88 @@ pub async fn admin_moderate_slug(
|
||||
return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response();
|
||||
}
|
||||
|
||||
// 1. Verify slug and get owner user ID
|
||||
let (owner_user_id, target_type) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
|
||||
[&payload.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
// 1. Verify slug using v0.8 slug lookup
|
||||
let slug_info = match state.lookup_slug(&payload.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
let target_type = slug_info.target_type.as_str().to_string();
|
||||
let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
Ok(tid) => tid,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid owner tenant ID on slug",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve owner username for log snapshot
|
||||
let owner_username = {
|
||||
// Resolve owner details from users.db for moderation event history
|
||||
let (owner_user_id, owner_username) = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
|
||||
.unwrap_or(None)
|
||||
.map(|u| u.username)
|
||||
match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) {
|
||||
Ok(Some(u)) => (u.id, Some(u.username)),
|
||||
_ => (0, None),
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Perform moderation update in global_slugs
|
||||
// 2. Perform moderation update in authoritative v0.8 slug databases
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
} else {
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync status to owner tenant's content DB if possible
|
||||
if let Ok(tenant_dbs) =
|
||||
state.open_tenant(owner_tenant_id, crate::db::tenant::TenantOpenMode::CoreJob)
|
||||
{
|
||||
if let Ok(conn) = tenant_dbs.content.lock() {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let content_status = if action == "disabled" {
|
||||
"dead"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET status = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_status, payload.slug],
|
||||
);
|
||||
} else {
|
||||
let content_state = if action == "disabled" {
|
||||
"archived"
|
||||
} else {
|
||||
"published"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE landing_pages SET state = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_state, payload.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Record moderation event in system.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
|
||||
// Record moderation event
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
|
||||
|
||||
+15
-32
@@ -4,11 +4,11 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -25,37 +25,15 @@ pub async fn resolve_page(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.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(),
|
||||
};
|
||||
stmt.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
};
|
||||
|
||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
// 1. Query global slug namespace across v0.8 slug databases (with fallback)
|
||||
let info = match state.lookup_slug(&code) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "page".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
if info.status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
@@ -64,10 +42,11 @@ pub async fn resolve_page(
|
||||
}
|
||||
|
||||
// 2. Get content database connection via tenant DB resolution
|
||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
let content_conn =
|
||||
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let page_opt = {
|
||||
let conn = content_conn.lock().unwrap();
|
||||
@@ -103,6 +82,9 @@ pub async fn resolve_page(
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let owner_tenant_id = crate::identity::TenantId::parse(&info.owner_tenant_id).ok();
|
||||
let owner_user_id = info.owner_tenant_id.parse::<i64>().ok();
|
||||
|
||||
let record = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "page".to_string(),
|
||||
@@ -114,7 +96,8 @@ pub async fn resolve_page(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 200,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
};
|
||||
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
+53
-24
@@ -7,7 +7,7 @@ use axum_extra::extract::{cookie::Cookie, CookieJar};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::password::verify_password;
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::state::AppState;
|
||||
use crate::templates::GateTemplate;
|
||||
|
||||
@@ -21,6 +21,43 @@ pub async fn gate_get(Path(code): Path<String>) -> impl IntoResponse {
|
||||
GateTemplate { code, error: None }
|
||||
}
|
||||
|
||||
enum GateLookup {
|
||||
Missing,
|
||||
Gone,
|
||||
Unprotected,
|
||||
Protected(String),
|
||||
}
|
||||
|
||||
fn lookup_gate(state: &AppState, code: &str) -> Result<GateLookup, axum::http::StatusCode> {
|
||||
let info = match state
|
||||
.lookup_slug(code)
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
Some(info) if info.status == "active" => info,
|
||||
Some(_) => return Ok(GateLookup::Gone),
|
||||
None => return Ok(GateLookup::Missing),
|
||||
};
|
||||
|
||||
let user_dbs = match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return Ok(GateLookup::Missing),
|
||||
};
|
||||
let conn = user_dbs
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match crate::db::content::get_url_by_code(&conn, code)
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
Some(u) => match u.password_hash {
|
||||
Some(h) => Ok(GateLookup::Protected(h)),
|
||||
None => Ok(GateLookup::Unprotected),
|
||||
},
|
||||
None => Ok(GateLookup::Missing),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /gate/:code
|
||||
pub async fn gate_post(
|
||||
State(state): State<AppState>,
|
||||
@@ -29,32 +66,25 @@ pub async fn gate_post(
|
||||
headers: axum::http::HeaderMap,
|
||||
Form(form): Form<PasswordGateForm>,
|
||||
) -> Response {
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Database error",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
};
|
||||
|
||||
let password_hash = match url.password_hash {
|
||||
Some(ref h) => h,
|
||||
None => {
|
||||
// Not password protected, redirect to resolution
|
||||
let password_hash = match lookup_gate(&state, &code) {
|
||||
Ok(GateLookup::Protected(h)) => h,
|
||||
Ok(GateLookup::Unprotected) => {
|
||||
return Redirect::temporary(&format!("/{}", code)).into_response();
|
||||
}
|
||||
Ok(GateLookup::Missing) => {
|
||||
return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response();
|
||||
}
|
||||
Ok(GateLookup::Gone) => {
|
||||
return (
|
||||
axum::http::StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(status) => return (status, "Database error").into_response(),
|
||||
};
|
||||
|
||||
if verify_password(&form.password, password_hash) {
|
||||
// Correct password - set 15 min temporary cookie
|
||||
if verify_password(&form.password, &password_hash) {
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build((cookie_name, "authorized"))
|
||||
@@ -67,7 +97,6 @@ pub async fn gate_post(
|
||||
let updated_jar = jar.add(cookie);
|
||||
(updated_jar, Redirect::temporary(&format!("/{}", code))).into_response()
|
||||
} else {
|
||||
// Invalid password
|
||||
GateTemplate {
|
||||
code,
|
||||
error: Some("Invalid password".to_string()),
|
||||
|
||||
+25
-63
@@ -1,3 +1,4 @@
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -37,57 +38,36 @@ pub async fn qr_handler(
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
// 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, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![&file], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
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()
|
||||
}
|
||||
}
|
||||
// Look up slug info from v0.8 slug registry (with fallback)
|
||||
let info = match state.lookup_slug(&file) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
if slug_status == "disabled" {
|
||||
if info.status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
} else if info.status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
let user_dbs = match state
|
||||
.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
|
||||
crate::db::qr::get_qr_scan_count(&conn, &info.target_id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
|
||||
rusqlite::params![target_id],
|
||||
rusqlite::params![info.target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
@@ -109,36 +89,16 @@ pub async fn qr_handler(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// 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, target_type, target_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
// Look up slug info from v0.8 slug registry (with fallback)
|
||||
let info = match state.lookup_slug(code) {
|
||||
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 == "disabled" {
|
||||
if info.status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
} else if info.status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
@@ -159,7 +119,7 @@ pub async fn qr_handler(
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||
|
||||
let full_url = if target_type == "page" {
|
||||
let full_url = if info.target_type == crate::db::slugs::SlugTargetType::LandingPage {
|
||||
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
|
||||
} else {
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), code)
|
||||
@@ -204,11 +164,13 @@ pub async fn qr_handler(
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
|
||||
if let Ok(user_dbs) =
|
||||
state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
if let Ok(analytics_conn) = user_dbs.analytics.lock() {
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&target_id,
|
||||
&info.target_id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
|
||||
+46
-72
@@ -15,18 +15,26 @@ use axum::{
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::{LinkPreview, Url, VisitRecord};
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
struct ResolvedUrl {
|
||||
owner_tenant_id: Option<TenantId>,
|
||||
owner_user_id: i64,
|
||||
url: Url,
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
|
||||
enum ResolveOutcome {
|
||||
Ready(Box<ResolvedUrl>),
|
||||
@@ -111,79 +119,40 @@ fn permanent_redirect_to(destination: &str, code: &str) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of the initial global-slug + URL resolution phase.
|
||||
struct ResolvedUrl {
|
||||
owner_user_id: i64,
|
||||
url: Url,
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
/// Lookup global slug namespace then load the URL from the tenant content DB.
|
||||
/// Runs entirely on a blocking thread.
|
||||
fn resolve_url_blocking(
|
||||
system_db: Arc<Mutex<rusqlite::Connection>>,
|
||||
_system_db: Arc<Mutex<rusqlite::Connection>>,
|
||||
state: AppState,
|
||||
code: &str,
|
||||
) -> ResolveOutcome {
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = match system_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "lock_system_db",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "prepare_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "query_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
// 1. Query global slug namespace across v0.8 slug databases (with fallback)
|
||||
let slug_info = match state.lookup_slug(code) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "lookup_slug",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||
let info = match slug_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
body: "Not Found",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let owner_user_id = info.owner_tenant_id.parse::<i64>().unwrap_or(0);
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
if info.status != "active" {
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::GONE,
|
||||
body: "This content has been disabled or moderated",
|
||||
@@ -191,22 +160,23 @@ fn resolve_url_blocking(
|
||||
}
|
||||
|
||||
// If target type is page, redirect permanently to /p/slug
|
||||
if target_type == "page" {
|
||||
if info.target_type == crate::db::slugs::SlugTargetType::LandingPage {
|
||||
return ResolveOutcome::PermanentPath(format!("/p/{}", code));
|
||||
}
|
||||
|
||||
// 2. Get content database connection via tenant DB resolution
|
||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "get_user_dbs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let content_conn =
|
||||
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "open_slug_owner",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let url = {
|
||||
let conn = match content_conn.content.lock() {
|
||||
@@ -239,7 +209,10 @@ fn resolve_url_blocking(
|
||||
}
|
||||
};
|
||||
|
||||
let owner_tenant_id = TenantId::parse(&info.owner_tenant_id).ok();
|
||||
|
||||
ResolveOutcome::Ready(Box::new(ResolvedUrl {
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
url,
|
||||
content: content_conn.content,
|
||||
@@ -347,6 +320,7 @@ pub async fn resolve_redirect(
|
||||
};
|
||||
|
||||
let ResolvedUrl {
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
url,
|
||||
content,
|
||||
@@ -424,7 +398,6 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
};
|
||||
|
||||
// Asynchronously record analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers
|
||||
@@ -454,6 +427,7 @@ pub async fn resolve_redirect(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: if preview_opt.is_some() { 200 } else { 302 },
|
||||
owner_tenant_id,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
|
||||
+34
-10
@@ -97,22 +97,46 @@ pub async fn metrics_endpoint(
|
||||
|
||||
// 1. Gather stats from DBs
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::get_landing_page_count(&conn).unwrap_or(0)
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let (total_clicks, total_page_views) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
|
||||
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0),
|
||||
)
|
||||
let total_clicks = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
let total_page_views = 0i64;
|
||||
|
||||
// Calculate memory in bytes
|
||||
let mut mem_bytes = 0;
|
||||
|
||||
Reference in new issue
Block a user