BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation
This commit is contained in:
1 parent
743502b183
commit
7dfb8c0f1b
100 files changed
+15588
-309
No files matched your search
+3807
-75
File diff suppressed because it is too large.
Load diff
+14
-14
@@ -167,7 +167,7 @@ pub async fn api_create_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
@@ -180,7 +180,7 @@ pub async fn api_create_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_CREATION",
|
||||
"url",
|
||||
&url.id,
|
||||
@@ -314,7 +314,7 @@ pub async fn api_update_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_UPDATE",
|
||||
Some("url"),
|
||||
Some(&uuid),
|
||||
@@ -327,7 +327,7 @@ pub async fn api_update_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_UPDATE",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -369,7 +369,7 @@ pub async fn api_delete_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&uuid),
|
||||
@@ -382,7 +382,7 @@ pub async fn api_delete_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_DELETION",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -448,7 +448,7 @@ pub async fn api_create_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
@@ -549,7 +549,7 @@ pub async fn api_update_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_UPDATE",
|
||||
Some("page"),
|
||||
Some(&uuid),
|
||||
@@ -590,7 +590,7 @@ pub async fn api_delete_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&uuid),
|
||||
@@ -889,7 +889,7 @@ pub async fn api_set_preview(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"SET_PREVIEW",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -976,7 +976,7 @@ pub async fn api_delete_preview(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"DELETE_PREVIEW",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1051,7 +1051,7 @@ pub async fn api_set_password(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"SET_PASSWORD",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1109,7 +1109,7 @@ pub async fn api_remove_password(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"REMOVE_PASSWORD",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1177,7 +1177,7 @@ pub async fn api_create_qr(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"CREATE_QR",
|
||||
"qr_code",
|
||||
&payload.url_id,
|
||||
|
||||
+2
-2
@@ -120,7 +120,7 @@ pub async fn api_bulk_qr(
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"BULK_QR_EXPORT",
|
||||
"bulk",
|
||||
"qr",
|
||||
@@ -271,7 +271,7 @@ pub async fn api_bulk_url(
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"BULK_URL_CREATION",
|
||||
"bulk",
|
||||
"url",
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod admin;
|
||||
pub mod api;
|
||||
pub mod bulk;
|
||||
pub mod middleware;
|
||||
pub mod multi_user;
|
||||
pub mod pages;
|
||||
pub mod password_gate;
|
||||
pub mod qr;
|
||||
|
||||
@@ -0,0 +1,991 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::ApiUser;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub account_type: Option<String>,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub status: String,
|
||||
pub account_type: String,
|
||||
pub created_at: String,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UpdateUserStatusRequest {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UpdateUserQuotasRequest {
|
||||
pub max_urls: i64,
|
||||
pub max_landings: i64,
|
||||
pub max_api_tokens: i64,
|
||||
pub max_storage_mb: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ResetPasswordRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TransferSlugRequest {
|
||||
pub slug: String,
|
||||
pub new_owner_user_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ModerateSlugRequest {
|
||||
pub slug: String,
|
||||
pub action: String, // 'flagged', 'disabled', 'active'
|
||||
pub severity: String, // 'low', 'medium', 'high', 'critical'
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ModerationEventResponse {
|
||||
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(Serialize, Deserialize)]
|
||||
pub struct ChangeOwnPasswordRequest {
|
||||
pub old_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateApiTokenRequest {
|
||||
// No request body needed, token is generated securely
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateApiTokenResponse {
|
||||
pub id: i64,
|
||||
pub token: String, // Cleartext token returned once
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// Helper: Ensure the request actor is an Admin
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn require_admin_role(user: &ApiUser) -> Result<&crate::models::User, Response> {
|
||||
match &user.0 {
|
||||
ApiActor::Admin(admin) => Ok(admin),
|
||||
_ => Err((StatusCode::FORBIDDEN, "Admin privileges required").into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin: User CRUD Endpoints ---
|
||||
|
||||
// GET /api/v1/admin/users
|
||||
pub async fn admin_list_users(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
if let Err(err_resp) = require_admin_role(&user) {
|
||||
return err_resp;
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::list_users(&conn) {
|
||||
Ok(users) => {
|
||||
let resp: Vec<UserResponse> = users
|
||||
.into_iter()
|
||||
.map(|u| UserResponse {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
status: u.status,
|
||||
account_type: u.account_type,
|
||||
created_at: u.created_at,
|
||||
metadata: u.metadata,
|
||||
})
|
||||
.collect();
|
||||
Json(resp).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users
|
||||
pub async fn admin_create_user(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<CreateUserRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
// Username validation: minimum 3 chars, alphanumeric, hyphen, underscore
|
||||
let username = payload.username.trim().to_lowercase();
|
||||
if username.len() < 3 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Username must be at least 3 characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !username
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Username must contain only alphanumeric characters, hyphens, or underscores",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Hash password
|
||||
let hash = match crate::auth::password::hash_password(&payload.password) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Hashing error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let account_type = payload.account_type.as_deref().unwrap_or("standard");
|
||||
match crate::db::users::create_user(
|
||||
&conn,
|
||||
&username,
|
||||
&hash,
|
||||
account_type,
|
||||
payload.metadata.as_deref(),
|
||||
) {
|
||||
Ok(new_user) => {
|
||||
// Write system audit event
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"USER_CREATION",
|
||||
"user",
|
||||
&new_user.id.to_string(),
|
||||
Some(&format!("Username: {}", new_user.username)),
|
||||
);
|
||||
}
|
||||
|
||||
Json(UserResponse {
|
||||
id: new_user.id,
|
||||
username: new_user.username,
|
||||
status: new_user.status,
|
||||
account_type: new_user.account_type,
|
||||
created_at: new_user.created_at,
|
||||
metadata: new_user.metadata,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
(StatusCode::CONFLICT, "Username already exists").into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/users/:id/status
|
||||
pub async fn admin_update_user_status(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(target_id): Path<i64>,
|
||||
Json(payload): Json<UpdateUserStatusRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let status = payload.status.trim().to_lowercase();
|
||||
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid user status").into_response();
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::update_user_status(&conn, target_id, &status) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"USER_STATUS_UPDATE",
|
||||
"user",
|
||||
&target_id.to_string(),
|
||||
Some(&format!("New Status: {}", status)),
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/users/:id/quotas
|
||||
pub async fn admin_update_user_quotas(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(target_id): Path<i64>,
|
||||
Json(payload): Json<UpdateUserQuotasRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::update_user_quotas(
|
||||
&conn,
|
||||
target_id,
|
||||
payload.max_urls,
|
||||
payload.max_landings,
|
||||
payload.max_api_tokens,
|
||||
payload.max_storage_mb,
|
||||
) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"USER_QUOTA_UPDATE",
|
||||
"user",
|
||||
&target_id.to_string(),
|
||||
Some(&format!(
|
||||
"max_urls: {}, max_landings: {}, max_api_tokens: {}, max_storage_mb: {}",
|
||||
payload.max_urls,
|
||||
payload.max_landings,
|
||||
payload.max_api_tokens,
|
||||
payload.max_storage_mb
|
||||
)),
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users/:id/password
|
||||
pub async fn admin_reset_user_password(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(target_id): Path<i64>,
|
||||
Json(payload): Json<ResetPasswordRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let hash = match crate::auth::password::hash_password(&payload.password) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Hashing error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::reset_user_password(&conn, target_id, &hash) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"USER_PASSWORD_RESET",
|
||||
"user",
|
||||
&target_id.to_string(),
|
||||
None,
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_user_resources(
|
||||
state: &AppState,
|
||||
target_id: i64,
|
||||
admin_username: &str,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
if target_id == 1 && !force {
|
||||
return Err("Deleting legacy_admin system account requires force flag".to_string());
|
||||
}
|
||||
|
||||
let user_details = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::get_user_by_id(&conn, target_id) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return Err("User not found".to_string()),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
// 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())?;
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for slug in slugs {
|
||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
||||
let _ = tx.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))?;
|
||||
}
|
||||
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_id.to_string());
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::delete_user(&conn, target_id).map_err(|e| e.to_string())?;
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
admin_username,
|
||||
"USER_DELETION",
|
||||
"user",
|
||||
&target_id.to_string(),
|
||||
Some(&format!("Username: {}", user_details.username)),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/users/:id
|
||||
pub async fn admin_delete_user(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(target_id): Path<i64>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let force = params.get("force").map(|v| v == "true").unwrap_or(false);
|
||||
match delete_user_resources(&state, target_id, &admin.username, force) {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(err) if err == "User not found" => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(err) if err == "Deleting legacy_admin system account requires force flag" => {
|
||||
(StatusCode::BAD_REQUEST, err).into_response()
|
||||
}
|
||||
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin: Slug Transfer ---
|
||||
|
||||
// POST /api/v1/admin/transfers
|
||||
pub async fn admin_transfer_slug(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<TransferSlugRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
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(),
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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 ---
|
||||
|
||||
// POST /api/v1/admin/moderation
|
||||
pub async fn admin_moderate_slug(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<ModerateSlugRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let action = payload.action.trim().to_lowercase();
|
||||
if !["flagged", "disabled", "active"].contains(&action.as_str()) {
|
||||
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);
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve owner username for log snapshot
|
||||
let 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)
|
||||
};
|
||||
|
||||
// 2. Perform moderation update in global_slugs
|
||||
{
|
||||
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);",
|
||||
rusqlite::params![
|
||||
event_id,
|
||||
now,
|
||||
admin.username,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
target_type,
|
||||
payload.slug,
|
||||
action,
|
||||
payload.severity,
|
||||
payload.reason
|
||||
],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"CONTENT_MODERATION",
|
||||
"slug",
|
||||
&payload.slug,
|
||||
Some(&format!("Action: {}, Reason: {}", action, payload.reason)),
|
||||
);
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/moderation/events
|
||||
pub async fn admin_list_moderation_events(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
) -> Response {
|
||||
if let Err(err_resp) = require_admin_role(&user) {
|
||||
return err_resp;
|
||||
}
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_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;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(ModerationEventResponse {
|
||||
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)?,
|
||||
})
|
||||
});
|
||||
|
||||
match rows {
|
||||
Ok(mapped) => {
|
||||
let events: Vec<ModerationEventResponse> = mapped.filter_map(|r| r.ok()).collect();
|
||||
Json(events).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- User: Dashboard, profile settings, API tokens ---
|
||||
|
||||
// GET /api/v1/user/profile
|
||||
pub async fn user_get_profile(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Profile endpoints are for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
|
||||
|
||||
Json(serde_json::json!({
|
||||
"id": tenant_user.id,
|
||||
"username": tenant_user.username,
|
||||
"status": tenant_user.status,
|
||||
"account_type": tenant_user.account_type,
|
||||
"created_at": tenant_user.created_at,
|
||||
"metadata": tenant_user.metadata,
|
||||
"quotas": quotas,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// POST /api/v1/user/password
|
||||
pub async fn user_change_password(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<ChangeOwnPasswordRequest>,
|
||||
) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Change password is for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify old password
|
||||
if !crate::auth::password::verify_password(&payload.old_password, &tenant_user.password_hash) {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid current password").into_response();
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
let hash = match crate::auth::password::hash_password(&payload.new_password) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Hashing error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::reset_user_password(&users_conn, tenant_user.id, &hash) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&tenant_user.username,
|
||||
"PASSWORD_CHANGE",
|
||||
"user",
|
||||
&tenant_user.id.to_string(),
|
||||
None,
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/user/api-tokens
|
||||
pub async fn user_list_api_tokens(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"API tokens are for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::list_user_api_tokens(&users_conn, tenant_user.id) {
|
||||
Ok(tokens) => Json(tokens).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/user/api-tokens
|
||||
pub async fn user_create_api_token(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"API tokens are for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Quota check
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
|
||||
if let Some(quota) = quotas {
|
||||
if quota.current_api_tokens >= quota.max_api_tokens {
|
||||
return (StatusCode::BAD_REQUEST, "API tokens quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Generate secure token
|
||||
let token_secret = format!("bzo_{}", crate::auth::session::generate_token(16)); // bzo_ followed by 32 hex chars
|
||||
|
||||
// Hash token using SHA-256 for storing
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token_secret.as_bytes());
|
||||
let token_hash = hex::encode(hasher.finalize());
|
||||
|
||||
match crate::db::users::create_user_api_token(&users_conn, tenant_user.id, &token_hash) {
|
||||
Ok(api_token) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&tenant_user.username,
|
||||
"API_TOKEN_CREATION",
|
||||
"api_token",
|
||||
&api_token.id.to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
Json(CreateApiTokenResponse {
|
||||
id: api_token.id,
|
||||
token: token_secret, // Return cleartext once
|
||||
created_at: api_token.created_at,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/user/api-tokens/:id
|
||||
pub async fn user_delete_api_token(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(token_id): Path<i64>,
|
||||
) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"API tokens are for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::delete_user_api_token(&users_conn, token_id, tenant_user.id) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&tenant_user.username,
|
||||
"API_TOKEN_DELETION",
|
||||
"api_token",
|
||||
&token_id.to_string(),
|
||||
None,
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
+51
-3
@@ -4,12 +4,12 @@ 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::models::VisitRecord;
|
||||
use crate::services::landing_pages::get_landing_page_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
@@ -25,11 +25,58 @@ pub async fn resolve_page(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let page_opt = match get_landing_page_by_code(&state.db, &code) {
|
||||
Ok(page) => page,
|
||||
// 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 {
|
||||
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())
|
||||
}
|
||||
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" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let page_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_landing_page_by_code(&conn, &code) {
|
||||
Ok(page) => page,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
match page_opt {
|
||||
Some(page) => {
|
||||
// Check state
|
||||
@@ -67,6 +114,7 @@ pub async fn resolve_page(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 200,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
+85
-10
@@ -1,5 +1,4 @@
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
use axum::{
|
||||
@@ -11,6 +10,7 @@ use std::net::SocketAddr;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
pub async fn qr_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -24,9 +24,10 @@ pub async fn qr_handler(
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
|
||||
let authenticated = if let Some(auth) = auth_header {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let admin_conn = state.admin_db.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
matches!(
|
||||
crate::auth::session::authenticate_api_key(&conn, auth),
|
||||
crate::auth::session::authenticate_api_key(&admin_conn, &users_conn, auth),
|
||||
Ok(Some(_user))
|
||||
)
|
||||
} else {
|
||||
@@ -37,23 +38,63 @@ pub async fn qr_handler(
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, &file) {
|
||||
Ok(u) => u,
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
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)?))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &file) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
|
||||
rusqlite::params![url.id],
|
||||
@@ -76,11 +117,45 @@ pub async fn qr_handler(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, code) {
|
||||
Ok(u) => u,
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
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)?))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Url not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, code) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
@@ -144,7 +219,7 @@ pub async fn qr_handler(
|
||||
.map(|s| s.to_string());
|
||||
|
||||
{
|
||||
let analytics_conn = state.db.analytics.lock().unwrap();
|
||||
let analytics_conn = user_dbs.analytics.lock().unwrap();
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&url.id,
|
||||
|
||||
+59
-11
@@ -5,12 +5,12 @@ use axum::{
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -29,17 +29,64 @@ pub async fn resolve_redirect(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
Ok(url) => url,
|
||||
// 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 {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(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())
|
||||
}
|
||||
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" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
};
|
||||
|
||||
// 1. Expiration check
|
||||
// 3. Expiration check
|
||||
if url.expired {
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
@@ -49,7 +96,7 @@ pub async fn resolve_redirect(
|
||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||
// Mark as expired in DB asynchronously/immediately
|
||||
{
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||
[url.id.clone()],
|
||||
@@ -60,7 +107,7 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Access limit check
|
||||
// 4. Access limit check
|
||||
if url.is_access_exhausted() {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
@@ -69,7 +116,7 @@ pub async fn resolve_redirect(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 3. Password protection check
|
||||
// 5. Password protection check
|
||||
if url.is_password_protected() {
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let authorized = jar
|
||||
@@ -82,14 +129,14 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Increment access count & retrieve preview config
|
||||
// 6. Increment access count & retrieve preview config
|
||||
let _new_access_count = {
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
||||
};
|
||||
|
||||
let preview_opt = {
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||
};
|
||||
|
||||
@@ -123,12 +170,13 @@ pub async fn resolve_redirect(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: if preview_opt.is_some() { 200 } else { 302 },
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
// Push to memory queue (non-blocking)
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// 5. Render Preview or Redirect
|
||||
// 7. Render Preview or Redirect
|
||||
if let Some(preview) = preview_opt {
|
||||
PreviewTemplate {
|
||||
code,
|
||||
|
||||
+140
-2
@@ -1,7 +1,7 @@
|
||||
use crate::state::AppState;
|
||||
use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system};
|
||||
use crate::web::{admin, api, bulk, multi_user, pages, password_gate, qr, redirect, system};
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,46 @@ pub fn create_router(state: AppState) -> Router {
|
||||
// --- System Health & Diagnostics ---
|
||||
.route("/status", get(system::status_endpoint))
|
||||
.route("/metrics", get(system::metrics_endpoint))
|
||||
// --- Public User Login ---
|
||||
.route(
|
||||
"/login",
|
||||
get(admin::public_login_get).post(admin::public_login_post),
|
||||
)
|
||||
.route("/logout", get(admin::public_logout))
|
||||
.route("/user/dashboard", get(admin::user_dashboard_get))
|
||||
.route("/user/urls", get(admin::user_urls_get))
|
||||
.route("/user/urls/create", post(admin::user_urls_create))
|
||||
.route("/user/urls/delete/:id", post(admin::user_urls_delete))
|
||||
.route("/user/audit", get(admin::user_audit_get))
|
||||
.route("/user/status", get(admin::user_status_get))
|
||||
.route("/user/pages", get(admin::user_pages_get))
|
||||
.route("/user/pages/create", post(admin::user_pages_create))
|
||||
.route("/user/pages/delete/:id", post(admin::user_pages_delete))
|
||||
.route("/user/settings", get(admin::user_settings_get))
|
||||
.route(
|
||||
"/user/settings/password",
|
||||
post(admin::user_change_password_post),
|
||||
)
|
||||
.route("/user/settings/backup", get(admin::user_download_backup))
|
||||
.route(
|
||||
"/user/settings/restore",
|
||||
post(admin::user_restore_backup_post),
|
||||
)
|
||||
.route("/analytics", get(admin::user_analytics_get))
|
||||
.route(
|
||||
"/user/analytics/url/:id",
|
||||
get(admin::user_url_analytics_get),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id",
|
||||
get(admin::user_page_analytics_get),
|
||||
)
|
||||
.route("/api-tokens", get(admin::api_tokens_get))
|
||||
.route("/api-tokens/create", post(admin::api_tokens_create_post))
|
||||
.route(
|
||||
"/api-tokens/revoke/:id",
|
||||
post(admin::api_tokens_revoke_post),
|
||||
)
|
||||
// --- Admin UI Login/Logout ---
|
||||
.route("/admin", get(admin::admin_index))
|
||||
.route(
|
||||
@@ -57,6 +97,24 @@ pub fn create_router(state: AppState) -> Router {
|
||||
get(admin::page_analytics_json_export),
|
||||
)
|
||||
.route("/admin/settings", get(admin::settings_get))
|
||||
.route("/admin/users", get(admin::users_get))
|
||||
.route("/admin/users/new", get(admin::users_new_get))
|
||||
.route("/admin/users/:id", get(admin::user_detail_get))
|
||||
.route(
|
||||
"/admin/users/:id/edit",
|
||||
get(admin::user_edit_get).post(admin::user_edit_post),
|
||||
)
|
||||
.route("/admin/users/create", post(admin::users_create_post))
|
||||
.route(
|
||||
"/admin/users/status/:id",
|
||||
post(admin::users_update_status_post),
|
||||
)
|
||||
.route("/admin/users/type/:id", post(admin::users_update_type_post))
|
||||
.route(
|
||||
"/admin/users/password/:id",
|
||||
post(admin::users_reset_password_post),
|
||||
)
|
||||
.route("/admin/users/delete/:id", post(admin::users_delete_post))
|
||||
.route(
|
||||
"/admin/settings/password",
|
||||
post(admin::change_password_post),
|
||||
@@ -79,6 +137,39 @@ pub fn create_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/admin/audit", get(admin::audit_get))
|
||||
.route("/admin/status", get(admin::status_get))
|
||||
.route(
|
||||
"/admin/moderation",
|
||||
get(admin::moderation_get).post(admin::moderation_post),
|
||||
)
|
||||
.route("/admin/slugs", get(admin::slugs_get))
|
||||
.route("/admin/slugs/transfer", post(admin::slugs_transfer_post))
|
||||
.route("/admin/slugs/status", post(admin::slugs_status_post))
|
||||
.route("/admin/slugs/delete", post(admin::slugs_delete_post))
|
||||
.route("/admin/sessions", get(admin::sessions_get))
|
||||
.route(
|
||||
"/admin/sessions/revoke/:id",
|
||||
post(admin::sessions_revoke_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/sessions/revoke-all",
|
||||
post(admin::sessions_revoke_all_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/quotas",
|
||||
get(admin::quotas_get).post(admin::quotas_post),
|
||||
)
|
||||
.route("/admin/health", get(admin::health_get))
|
||||
.route("/admin/backups", get(admin::backups_get))
|
||||
.route("/admin/backups/create", post(admin::backups_create_post))
|
||||
.route(
|
||||
"/admin/backups/download/:filename",
|
||||
get(admin::backups_download_get),
|
||||
)
|
||||
.route(
|
||||
"/admin/backups/delete/:filename",
|
||||
post(admin::backups_delete_post),
|
||||
)
|
||||
.route("/admin/backups/restore", post(admin::backups_restore_post))
|
||||
// --- REST API v1 JSON Endpoints ---
|
||||
.route(
|
||||
"/api/v1/urls",
|
||||
@@ -124,6 +215,53 @@ pub fn create_router(state: AppState) -> Router {
|
||||
"/api/v1/urls/:uuid/password",
|
||||
post(api::api_set_password).delete(api::api_remove_password),
|
||||
)
|
||||
// --- Multi-User REST API v1 Admin Endpoints ---
|
||||
.route(
|
||||
"/api/v1/admin/users",
|
||||
get(multi_user::admin_list_users).post(multi_user::admin_create_user),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/status",
|
||||
put(multi_user::admin_update_user_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/quotas",
|
||||
put(multi_user::admin_update_user_quotas),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/password",
|
||||
post(multi_user::admin_reset_user_password),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id",
|
||||
delete(multi_user::admin_delete_user),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/transfers",
|
||||
post(multi_user::admin_transfer_slug),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/moderation",
|
||||
post(multi_user::admin_moderate_slug),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/moderation/events",
|
||||
get(multi_user::admin_list_moderation_events),
|
||||
)
|
||||
// --- Multi-User REST API v1 Tenant User Endpoints ---
|
||||
.route("/api/v1/user/profile", get(multi_user::user_get_profile))
|
||||
.route(
|
||||
"/api/v1/user/password",
|
||||
post(multi_user::user_change_password),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/api-tokens",
|
||||
get(multi_user::user_list_api_tokens).post(multi_user::user_create_api_token),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/api-tokens/:id",
|
||||
delete(multi_user::user_delete_api_token),
|
||||
)
|
||||
// --- Static Asset Stub ---
|
||||
.route(
|
||||
"/static/style.css",
|
||||
|
||||
+6
-5
@@ -6,7 +6,7 @@ use axum::{
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::{authenticate_api_key, authenticate_session};
|
||||
use crate::auth::{authenticate_admin_session, authenticate_api_key};
|
||||
use crate::db::admin::get_user_count;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_db_file_info, get_memory_usage};
|
||||
@@ -15,15 +15,16 @@ use crate::utils::{get_db_file_info, get_memory_usage};
|
||||
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
|
||||
// 1. Try Authorization header
|
||||
if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_api_key(&conn, auth_header) {
|
||||
let admin_conn = state.admin_db.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_api_key(&admin_conn, &users_conn, auth_header) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try cookie session
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_session(&conn, jar) {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_admin_session(&conn, jar) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user