Files
nx9-url-shortener/src/web/admin/moderation.rs
T

674 lines
23 KiB
Rust

use super::*;
use crate::identity::TenantId;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct GlobalSlugRow {
pub slug: String,
pub owner_user_id: i64,
pub target_type: String,
pub target_id: String,
pub created_at: String,
pub updated_at: String,
pub status: String,
pub deleted_at: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ModerationLogEntry {
pub id: String,
pub timestamp: String,
pub admin_username: String,
pub target_user_id: i64,
pub target_username: Option<String>,
pub resource_type: String,
pub resource_identifier: String,
pub action: String,
pub severity: String,
pub reason: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SlugHistoryRow {
pub id: i64,
pub slug: String,
pub old_owner_user_id: Option<i64>,
pub new_owner_user_id: Option<i64>,
pub action: String,
pub timestamp: String,
pub admin_username: Option<String>,
}
// GET /admin/moderation
pub async fn moderation_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
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: 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)?,
})
})?;
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();
let mut stmt = conn.prepare(
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason \
FROM moderation_events ORDER BY timestamp DESC LIMIT 50;"
).unwrap();
let rows = stmt
.query_map([], |row| {
Ok(ModerationLogEntry {
id: row.get(0)?,
timestamp: row.get(1)?,
admin_username: row.get(2)?,
target_user_id: row.get(3)?,
target_username: row.get(4)?,
resource_type: row.get(5)?,
resource_identifier: row.get(6)?,
action: row.get(7)?,
severity: row.get(8)?,
reason: row.get(9)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::ModerationTemplate {
admin_username: user.username,
flagged_items,
logs,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
#[derive(Deserialize)]
pub struct AdminModerateForm {
pub slug: String,
pub action: String,
pub severity: String,
pub reason: String,
pub csrf_token: String,
}
// POST /admin/moderation
pub async fn moderation_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminModerateForm>,
) -> Response {
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/moderation?error=Invalid CSRF token").into_response();
}
let action = form.action.trim().to_lowercase();
if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) {
return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
}
let slug_info = match state.lookup_slug(&form.slug) {
Ok(Some(info)) => info,
_ => 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_user_id, owner_username) = {
let users_conn = state.users_db.lock().unwrap();
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 now = Utc::now().to_rfc3339();
// 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 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) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
rusqlite::params![
event_id,
now,
user.username,
owner_user_id,
owner_username,
slug_info.target_type.as_str(),
form.slug,
action,
form.severity,
form.reason
],
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"CONTENT_MODERATION",
"slug",
&form.slug,
Some(&format!("Action: {}, Reason: {}", action, form.reason)),
);
}
Redirect::to(&format!(
"/admin/moderation?success=Moderation action '{}' applied",
action
))
.into_response()
}
#[derive(Deserialize)]
pub struct SlugsQuery {
pub search: Option<String>,
pub owner: Option<i64>,
pub status: Option<String>,
pub success: Option<String>,
pub error: Option<String>,
}
// GET /admin/slugs
pub async fn slugs_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<SlugsQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut slugs: 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 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![];
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: 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)?,
})
})?;
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;"
).unwrap();
let rows = stmt
.query_map([], |row| {
Ok(SlugHistoryRow {
id: row.get(0)?,
slug: row.get(1)?,
old_owner_user_id: row.get(2)?,
new_owner_user_id: row.get(3)?,
action: row.get(4)?,
timestamp: row.get(5)?,
admin_username: row.get(6)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::SlugsTemplate {
admin_username: user.username,
slugs,
history,
csrf_token,
search_filter: query.search,
owner_filter: query.owner,
status_filter: query.status,
success: query.success,
error: query.error,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct AdminTransferForm {
pub slug: String,
pub new_owner_user_id: i64,
pub csrf_token: String,
}
// POST /admin/slugs/transfer
pub async fn slugs_transfer_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminTransferForm>,
) -> Response {
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/slugs?error=Invalid CSRF token").into_response();
}
let req = crate::services::slug_transfer::SlugTransferRequest {
slug: form.slug.clone(),
new_owner_user_id: form.new_owner_user_id,
};
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(),
}
}
#[derive(Deserialize)]
pub struct AdminSlugStatusForm {
pub slug: String,
pub status: String,
pub csrf_token: String,
}
// POST /admin/slugs/status
pub async fn slugs_status_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminSlugStatusForm>,
) -> Response {
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/slugs?error=Invalid CSRF token").into_response();
}
let status = form.status.trim().to_lowercase();
if !["active", "flagged", "disabled"].contains(&status.as_str()) {
return Redirect::to("/admin/slugs?error=Invalid status").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 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(
&system_conn,
&user.username,
"SLUG_STATUS_UPDATE",
"slug",
&form.slug,
Some(&format!("Status set to {}", status)),
);
}
Redirect::to(&format!(
"/admin/slugs?success=Slug /{} status updated to {}",
form.slug, status
))
.into_response()
}
#[derive(Deserialize)]
pub struct AdminSlugDeleteForm {
pub slug: String,
pub csrf_token: String,
}
// POST /admin/slugs/delete
pub async fn slugs_delete_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminSlugDeleteForm>,
) -> Response {
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/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 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(
&system_conn,
&user.username,
"SLUG_RELEASE",
"slug",
&form.slug,
Some("Slug released/deleted from global index"),
);
}
Redirect::to(&format!(
"/admin/slugs?success=Slug /{} released successfully",
form.slug
))
.into_response()
}