Add QR codes, link expiry, password protection, previews, audit trail and bulk operations
This commit is contained in:
1 parent
157aa81252
commit
bcbcc90d98
88 files changed
+5354
-815
No files matched your search
+499
-107
@@ -1,38 +1,77 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query, ConnectInfo},
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Redirect, Response, IntoResponse},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Form,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use tar::Builder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use rusqlite::params;
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use tar::Builder;
|
||||
|
||||
use crate::db::admin::{
|
||||
create_user, get_user_count, get_user_by_username, create_session, delete_session,
|
||||
write_audit_log, list_audit_logs, list_api_keys, create_api_key, delete_api_key, set_config, get_config
|
||||
create_api_key, create_session, create_user, delete_api_key, delete_session, get_config,
|
||||
get_user_by_username, get_user_count, list_api_keys, set_config,
|
||||
write_audit_log as write_audit_log_legacy,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_audit_log(
|
||||
conn: &rusqlite::Connection,
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
action: &str,
|
||||
object_type: Option<&str>,
|
||||
object_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> rusqlite::Result<crate::models::AuditLog> {
|
||||
let res = write_audit_log_legacy(
|
||||
conn,
|
||||
username,
|
||||
action,
|
||||
object_type,
|
||||
object_id,
|
||||
ip_address,
|
||||
user_agent,
|
||||
);
|
||||
|
||||
// Also write to unified audit events in system.db
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
username,
|
||||
action,
|
||||
object_type.unwrap_or(""),
|
||||
object_id.unwrap_or(""),
|
||||
Some(&metadata),
|
||||
);
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
use crate::auth::{
|
||||
authenticate_session, generate_csrf_token, generate_token, hash_password, verify_csrf,
|
||||
verify_password, verify_sha256,
|
||||
};
|
||||
use crate::charts::{generate_bar_chart, generate_line_chart};
|
||||
use crate::db::analytics::{
|
||||
get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw,
|
||||
get_total_clicks,
|
||||
};
|
||||
use crate::db::content::{
|
||||
list_urls, create_url, delete_url, get_url_counts, get_landing_page_count,
|
||||
list_landing_pages, create_landing_page, delete_landing_page
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_count, get_url_counts,
|
||||
list_landing_pages, list_urls,
|
||||
};
|
||||
use crate::db::analytics::{
|
||||
get_total_clicks, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw
|
||||
};
|
||||
use crate::auth::{
|
||||
authenticate_session, verify_password, verify_sha256, hash_password, generate_token,
|
||||
generate_csrf_token, verify_csrf
|
||||
};
|
||||
use crate::charts::{generate_line_chart, generate_bar_chart};
|
||||
use crate::state::AppState;
|
||||
use crate::models::User;
|
||||
use crate::utils::{get_client_ip, get_memory_usage, get_db_file_info};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage};
|
||||
|
||||
// Helper: Verify session and return user or redirect to login
|
||||
async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> {
|
||||
@@ -44,10 +83,7 @@ async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String
|
||||
}
|
||||
|
||||
// GET /admin
|
||||
pub async fn admin_index(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
match require_auth(&state, &jar).await {
|
||||
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
|
||||
Err(redir) => redir.into_response(),
|
||||
@@ -62,7 +98,7 @@ pub async fn login_get(
|
||||
) -> Response {
|
||||
let error = params.get("error").cloned();
|
||||
let csrf_token = generate_token(16);
|
||||
|
||||
|
||||
let mut new_jar = jar.clone();
|
||||
new_jar = new_jar.add(
|
||||
Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
|
||||
@@ -70,7 +106,7 @@ pub async fn login_get(
|
||||
.secure(state.config.cookie_secure)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.build()
|
||||
.build(),
|
||||
);
|
||||
|
||||
let template = crate::templates::LoginTemplate { error, csrf_token };
|
||||
@@ -92,7 +128,10 @@ pub async fn login_post(
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
let temp_csrf = jar.get("bzod_temp_csrf").map(|c| c.value().to_string()).unwrap_or_default();
|
||||
let temp_csrf = jar
|
||||
.get("bzod_temp_csrf")
|
||||
.map(|c| c.value().to_string())
|
||||
.unwrap_or_default();
|
||||
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
|
||||
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
@@ -106,16 +145,30 @@ pub async fn login_post(
|
||||
|
||||
let user_opt = if user_count == 0 {
|
||||
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
|
||||
if form.username == state.config.admin_username && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) {
|
||||
if form.username == state.config.admin_username
|
||||
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
|
||||
{
|
||||
let hash = match hash_password(&form.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Redirect::to("/admin/login?error=Internal hashing error").into_response(),
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal hashing error")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match create_user(&conn, &form.username, &hash) {
|
||||
Ok(u) => {
|
||||
let _ = write_audit_log(&conn, &u.username, "BOOTSTRAP_USER_PROVISIONED", Some("user"), Some(&u.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&u.username,
|
||||
"BOOTSTRAP_USER_PROVISIONED",
|
||||
Some("user"),
|
||||
Some(&u.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Some(u)
|
||||
}
|
||||
Err(_) => None,
|
||||
@@ -142,11 +195,20 @@ pub async fn login_post(
|
||||
Some(user) => {
|
||||
let session_token = generate_token(32);
|
||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
|
||||
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = create_session(&conn, &session_token, &user.id, &expires);
|
||||
let _ = write_audit_log(&conn, &user.username, "USER_LOGIN", Some("session"), Some(&session_token), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_LOGIN",
|
||||
Some("session"),
|
||||
Some(&session_token),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
|
||||
let cookie = Cookie::build(("bzod_session", session_token))
|
||||
@@ -170,7 +232,16 @@ pub async fn login_post(
|
||||
None => {
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn, "anonymous", "LOGIN_FAILED", None, None, Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
"anonymous",
|
||||
"LOGIN_FAILED",
|
||||
None,
|
||||
None,
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/login?error=Invalid username or password").into_response()
|
||||
}
|
||||
@@ -178,10 +249,7 @@ pub async fn login_post(
|
||||
}
|
||||
|
||||
// GET /admin/logout
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = delete_session(&conn, &session_id);
|
||||
@@ -199,10 +267,7 @@ pub async fn logout(
|
||||
}
|
||||
|
||||
// GET /admin/dashboard
|
||||
pub async fn dashboard_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
@@ -229,10 +294,12 @@ pub async fn dashboard_get(
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
|
||||
let mut trend_map = std::collections::BTreeMap::new();
|
||||
for i in (0..30).rev() {
|
||||
let date_str = (Utc::now() - chrono::Duration::days(i)).format("%Y-%m-%d").to_string();
|
||||
let date_str = (Utc::now() - chrono::Duration::days(i))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
trend_map.insert(date_str, 0i64);
|
||||
}
|
||||
for (d, c) in clicks_data {
|
||||
@@ -277,7 +344,7 @@ pub async fn dashboard_get(
|
||||
browsers_chart,
|
||||
referrers_chart,
|
||||
};
|
||||
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
@@ -305,12 +372,24 @@ pub async fn urls_get(
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let base_url = state
|
||||
.config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
|
||||
|
||||
let template = crate::templates::UrlsTemplate {
|
||||
admin_username: user.username,
|
||||
urls,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
tag_filter: query.tag,
|
||||
base_url,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
@@ -324,6 +403,9 @@ pub struct CreateUrlForm {
|
||||
pub description: String,
|
||||
pub tags: String,
|
||||
pub csrf_token: String,
|
||||
pub expires_at: String,
|
||||
pub password: String,
|
||||
pub max_access_count: String,
|
||||
}
|
||||
|
||||
// POST /admin/urls/create
|
||||
@@ -349,38 +431,97 @@ pub async fn urls_create(
|
||||
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();
|
||||
return Redirect::to("/admin/urls?error=Custom code must be exactly 6 hex characters")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let tags_list: Vec<String> = form.tags
|
||||
let expires_at_opt = if form.expires_at.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut rfc = form.expires_at.trim().to_string();
|
||||
if rfc.len() == 16 {
|
||||
rfc.push_str(":00Z"); // convert HTML datetime-local to standard UTC RFC3339
|
||||
}
|
||||
Some(rfc)
|
||||
};
|
||||
|
||||
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 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 res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_url(&conn, &code, &form.destination, title_opt, desc_opt, &tags_list)
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&form.destination,
|
||||
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 conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn, &user.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Redirect::to("/admin/urls?error=Short code already exists").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,11 +551,22 @@ pub async fn urls_delete(
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn_admin, &user.username, "URL_DELETION", Some("url"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +636,8 @@ pub async fn pages_create(
|
||||
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();
|
||||
return Redirect::to("/admin/pages?error=Custom code must be exactly 4 hex characters")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,18 +648,36 @@ pub async fn pages_create(
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_landing_page(&conn, &code, &clean_slug, &form.title, &form.html_content, &form.state)
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&clean_slug,
|
||||
&form.title,
|
||||
&form.html_content,
|
||||
&form.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn_admin, &user.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Redirect::to("/admin/pages?error=Short code already exists").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -541,11 +712,21 @@ pub async fn pages_delete(
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn_admin, &user.username, "PAGE_DELETION", Some("page"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
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()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e)).into_response(),
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +756,13 @@ pub async fn settings_get(
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
get_config(&conn, "retention_days")
|
||||
.unwrap_or(None)
|
||||
.unwrap_or_else(|| state.config.data_retention_days.map(|d| d.to_string()).unwrap_or_else(|| "unlimited".to_string()))
|
||||
.unwrap_or_else(|| {
|
||||
state
|
||||
.config
|
||||
.data_retention_days
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_else(|| "unlimited".to_string())
|
||||
})
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
@@ -620,7 +807,16 @@ pub async fn change_password_post(
|
||||
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if !verify_password(&form.current_password, &user.password_hash) {
|
||||
let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"PASSWORD_CHANGE_FAIL",
|
||||
Some("user"),
|
||||
Some(&user.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
return Redirect::to("/admin/settings?error=Incorrect current password").into_response();
|
||||
}
|
||||
|
||||
@@ -629,15 +825,29 @@ pub async fn change_password_post(
|
||||
Err(_) => return Redirect::to("/admin/settings?error=Hashing error").into_response(),
|
||||
};
|
||||
|
||||
let res = conn.execute("UPDATE users SET password_hash = ?1 WHERE id = ?2;", params![new_hash, user.id]);
|
||||
let res = conn.execute(
|
||||
"UPDATE users SET password_hash = ?1 WHERE id = ?2;",
|
||||
params![new_hash, user.id],
|
||||
);
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_SUCCESS", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"PASSWORD_CHANGE_SUCCESS",
|
||||
Some("user"),
|
||||
Some(&user.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/settings?success=Password updated successfully").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Failed to update password: {}", e)).into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to update password: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,10 +879,21 @@ pub async fn change_retention_post(
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match set_config(&conn, "retention_days", &form.retention) {
|
||||
Ok(_) => {
|
||||
let _ = write_audit_log(&conn, &user.username, "RETENTION_POLICY_CHANGED", Some("config"), Some("retention_days"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"RETENTION_POLICY_CHANGED",
|
||||
Some("config"),
|
||||
Some("retention_days"),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/settings?success=Retention policy saved").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,10 +914,22 @@ pub async fn compact_db_post(
|
||||
match state.db_compact() {
|
||||
Ok(_) => {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn, &user.username, "DATABASE_COMPACTION", Some("system"), Some("all_dbs"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
Redirect::to("/admin/settings?success=Database files compacted successfully").into_response()
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"DATABASE_COMPACTION",
|
||||
Some("system"),
|
||||
Some("all_dbs"),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/settings?success=Database files compacted successfully")
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,7 +952,7 @@ pub async fn download_backup(
|
||||
let res = {
|
||||
let enc = GzEncoder::new(&mut buffer, Compression::default());
|
||||
let mut tar = Builder::new(enc);
|
||||
|
||||
|
||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
||||
let mut add_err = None;
|
||||
for f in files {
|
||||
@@ -731,15 +964,13 @@ pub async fn download_backup(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
match add_err {
|
||||
Some(e) => Err(e),
|
||||
None => {
|
||||
match tar.into_inner().and_then(|encoder| encoder.finish()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
None => match tar.into_inner().and_then(|encoder| encoder.finish()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -747,7 +978,16 @@ pub async fn download_backup(
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn, &user.username, "DATABASE_BACKUP", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"DATABASE_BACKUP",
|
||||
Some("system"),
|
||||
Some("tarball"),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
|
||||
let date_str = Utc::now().format("%Y-%m-%d").to_string();
|
||||
@@ -757,10 +997,14 @@ pub async fn download_backup(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "application/gzip"),
|
||||
("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
buffer,
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response()
|
||||
@@ -793,8 +1037,8 @@ pub async fn create_api_key_post(
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let key_secret = format!("bzo_{}", generate_token(16));
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_secret.as_bytes());
|
||||
let hashed_key = hex::encode(hasher.finalize());
|
||||
@@ -802,13 +1046,24 @@ pub async fn create_api_key_post(
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) {
|
||||
Ok(api_key) => {
|
||||
let _ = write_audit_log(&conn, &user.username, "API_KEY_CREATED", Some("api_key"), Some(&api_key.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"API_KEY_CREATED",
|
||||
Some("api_key"),
|
||||
Some(&api_key.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to(&format!(
|
||||
"/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}",
|
||||
key_secret
|
||||
)).into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response(),
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,26 +1091,157 @@ pub async fn revoke_api_key_post(
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match delete_api_key(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = write_audit_log(&conn, &user.username, "API_KEY_REVOKED", Some("api_key"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"API_KEY_REVOKED",
|
||||
Some("api_key"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/settings?success=API Token revoked").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to revoke key: {}", e)).into_response(),
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to revoke key: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkQrExportForm {
|
||||
pub format: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/settings/bulk-qr
|
||||
pub async fn bulk_qr_export_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<BulkQrExportForm>,
|
||||
) -> 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/settings?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
if urls.is_empty() {
|
||||
return Redirect::to("/admin/settings?error=No shortened URLs found to export")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let host_header = headers
|
||||
.get("host")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("localhost:8654");
|
||||
let base_url = state
|
||||
.config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||
|
||||
match crate::services::bulk::export_qr_zip(&urls, &form.format, &base_url) {
|
||||
Ok(zip_data) => {
|
||||
// Write Audit Log
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"BULK_QR_EXPORT",
|
||||
Some("bulk"),
|
||||
Some("qr"),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
|
||||
Response::builder()
|
||||
.header("content-type", "application/zip")
|
||||
.header(
|
||||
"content-disposition",
|
||||
"attachment; filename=\"qr_codes.zip\"",
|
||||
)
|
||||
.body(axum::body::Body::from(zip_data))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Export failed: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/audit
|
||||
pub async fn audit_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let logs = {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
list_audit_logs(&conn, 100, 0).unwrap_or_default()
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None)
|
||||
.unwrap_or_default();
|
||||
events
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let (ip, ua) = if let Some(ref m) = e.metadata {
|
||||
if m.starts_with("IP: ") {
|
||||
let parts: Vec<&str> = m.split(", UA: ").collect();
|
||||
let ip = parts[0]
|
||||
.trim_start_matches("IP: ")
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.replace("Some(", "")
|
||||
.replace(")", "");
|
||||
let ua = if parts.len() > 1 {
|
||||
parts[1]
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.replace("Some(", "")
|
||||
.replace(")", "")
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
(Some(ip), Some(ua))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
crate::models::AuditLog {
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
username: e.actor,
|
||||
action: e.action,
|
||||
object_type: Some(e.object_type),
|
||||
object_id: Some(e.object_id),
|
||||
ip_address: ip,
|
||||
user_agent: ua,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let template = crate::templates::AuditTemplate {
|
||||
@@ -867,35 +1253,40 @@ pub async fn audit_get(
|
||||
}
|
||||
|
||||
// GET /admin/status
|
||||
pub async fn status_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Response {
|
||||
pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let app_status = "Healthy";
|
||||
|
||||
|
||||
let db_status = {
|
||||
let conn_ok = {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
get_user_count(&conn).is_ok()
|
||||
};
|
||||
if conn_ok {
|
||||
format!("Operational\n\nDatabase Files:\n{}", get_db_file_info(&state.config.data_dir))
|
||||
format!(
|
||||
"Operational\n\nDatabase Files:\n{}",
|
||||
get_db_file_info(&state.config.data_dir)
|
||||
)
|
||||
} else {
|
||||
"Degraded (Database connections failed)".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let queue_size = 0;
|
||||
let queue_size = 0;
|
||||
let memory_usage = get_memory_usage();
|
||||
|
||||
|
||||
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 template = crate::templates::StatusTemplate {
|
||||
admin_username: user.username,
|
||||
app_status,
|
||||
@@ -905,6 +1296,7 @@ pub async fn status_get(
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
urls,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
|
||||
+808
-57
File diff suppressed because it is too large.
Load diff
+288
@@ -0,0 +1,288 @@
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::auth::ApiUser;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkQrRequest {
|
||||
pub ids: Vec<String>,
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BulkUrlItem {
|
||||
pub destination: String,
|
||||
pub code: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub expires_at: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub max_access_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BulkErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
// POST /api/v1/bulk/qr
|
||||
pub async fn api_bulk_qr(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<BulkQrRequest>,
|
||||
) -> Response {
|
||||
if payload.ids.len() > 500 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Maximum 500 QR codes allowed per bulk request".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let format = payload
|
||||
.format
|
||||
.unwrap_or_else(|| "png".to_string())
|
||||
.to_lowercase();
|
||||
if format != "png" && format != "svg" {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Invalid format. Supported: png, svg".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Retrieve URLs from database
|
||||
let mut urls = Vec::new();
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
for id in &payload.ids {
|
||||
match crate::db::content::get_url_by_id(&conn, id) {
|
||||
Ok(Some(url)) => urls.push(url),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Database error fetching URL {}: {}", id, e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if urls.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "No valid URLs found for the provided IDs".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Base URL configuration check
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let host_header = headers
|
||||
.get("host")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("localhost:8654");
|
||||
let base_url = state
|
||||
.config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||
|
||||
// Generate ZIP
|
||||
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
|
||||
Ok(zip_data) => {
|
||||
// Write Audit Log
|
||||
{
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
"BULK_QR_EXPORT",
|
||||
"bulk",
|
||||
"qr",
|
||||
Some(&format!("Count: {}, Format: {}", urls.len(), format)),
|
||||
);
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.header("content-type", "application/zip")
|
||||
.header(
|
||||
"content-disposition",
|
||||
"attachment; filename=\"qr_codes.zip\"",
|
||||
)
|
||||
.body(axum::body::Body::from(zip_data))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Error generating ZIP: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/bulk/url
|
||||
pub async fn api_bulk_url(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<Vec<BulkUrlItem>>,
|
||||
) -> Response {
|
||||
if payload.len() > 500 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Maximum 500 URLs allowed per bulk creation".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut conn = state.content_db.lock().unwrap();
|
||||
let tx = match conn.transaction() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to start database transaction: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let mut created_urls = Vec::new();
|
||||
|
||||
for item in payload {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3); // 6 hex
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' must be 6 hex characters", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pwd) = item.password {
|
||||
match hash_password(pwd) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Password hashing error: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tags = item.tags.unwrap_or_default();
|
||||
match crate::db::content::create_url_extended(
|
||||
&tx,
|
||||
&code,
|
||||
&item.destination,
|
||||
item.title.as_deref(),
|
||||
item.description.as_deref(),
|
||||
&tags,
|
||||
item.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
item.max_access_count,
|
||||
) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' already exists", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Database insert error: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to commit transaction: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Write Audit Log for the entire batch
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
{
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
"BULK_URL_CREATION",
|
||||
"bulk",
|
||||
"url",
|
||||
Some(&format!(
|
||||
"Count: {}, IP: {:?}, User-Agent: {:?}",
|
||||
created_urls.len(),
|
||||
ip,
|
||||
user_agent
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
(StatusCode::CREATED, Json(created_urls)).into_response()
|
||||
}
|
||||
+7
-4
@@ -1,9 +1,12 @@
|
||||
pub mod routes;
|
||||
pub mod middleware;
|
||||
pub mod redirect;
|
||||
pub mod pages;
|
||||
pub mod admin;
|
||||
pub mod api;
|
||||
pub mod bulk;
|
||||
pub mod middleware;
|
||||
pub mod pages;
|
||||
pub mod password_gate;
|
||||
pub mod qr;
|
||||
pub mod redirect;
|
||||
pub mod routes;
|
||||
pub mod system;
|
||||
|
||||
pub use routes::create_router;
|
||||
+12
-9
@@ -1,17 +1,17 @@
|
||||
use axum::{
|
||||
extract::{Path, State, ConnectInfo},
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Response, Html, IntoResponse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::utils::get_client_ip;
|
||||
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;
|
||||
|
||||
// GET /p/:code and GET /p/:code/*slug
|
||||
// Resolve and render landing page
|
||||
@@ -40,15 +40,18 @@ pub async fn resolve_page(
|
||||
// Record view analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers.get("user-agent")
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let referer = headers.get("referer")
|
||||
let referer = headers
|
||||
.get("referer")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Direct")
|
||||
.to_string();
|
||||
let accept_language = headers.get("accept-language")
|
||||
let accept_language = headers
|
||||
.get("accept-language")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Form,
|
||||
};
|
||||
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::state::AppState;
|
||||
use crate::templates::GateTemplate;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PasswordGateForm {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
// GET /gate/:code
|
||||
pub async fn gate_get(Path(code): Path<String>) -> impl IntoResponse {
|
||||
GateTemplate { code, error: None }
|
||||
}
|
||||
|
||||
// POST /gate/:code
|
||||
pub async fn gate_post(
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
jar: CookieJar,
|
||||
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
|
||||
return Redirect::temporary(&format!("/{}", code)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if verify_password(&form.password, password_hash) {
|
||||
// Correct password - set 15 min temporary cookie
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let cookie = Cookie::build((cookie_name, "authorized"))
|
||||
.secure(state.config.cookie_secure)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.http_only(true)
|
||||
.path("/")
|
||||
.max_age(time::Duration::minutes(15));
|
||||
|
||||
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()),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
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::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
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)
|
||||
pub async fn qr_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(file): Path<String>,
|
||||
) -> Response {
|
||||
let parts: Vec<&str> = file.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
// No extension: this is a JSON stats request!
|
||||
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();
|
||||
matches!(
|
||||
crate::auth::session::authenticate_api_key(&conn, auth),
|
||||
Ok(Some(_user))
|
||||
)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !authenticated {
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, &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();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
|
||||
rusqlite::params![url.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
return axum::response::Json(json!({
|
||||
"direct_clicks": direct_clicks,
|
||||
"qr_scans": qr_scans
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let code = parts[0];
|
||||
let ext = parts[1].to_lowercase();
|
||||
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, code) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
};
|
||||
|
||||
// Construct public base URL
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let host_header = headers
|
||||
.get("host")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("localhost:8654");
|
||||
|
||||
let base_url = state
|
||||
.config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||
|
||||
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code);
|
||||
|
||||
// Generate QR code based on format
|
||||
let (body, content_type) = if ext == "svg" {
|
||||
match generate_qr_svg(&full_url) {
|
||||
Ok(svg) => (svg.into_bytes(), "image/svg+xml"),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("QR generation error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
} else if ext == "png" {
|
||||
match generate_qr_png(&full_url, 256) {
|
||||
Ok(png) => (png, "image/png"),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("QR generation error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Unsupported format. Use .png or .svg",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
// Log the QR access event
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
{
|
||||
let analytics_conn = state.db.analytics.lock().unwrap();
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&url.id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.header("content-type", content_type)
|
||||
.header("cache-control", "public, max-age=86400") // cache for 1 day
|
||||
.body(axum::body::Body::from(body))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
+113
-41
@@ -1,22 +1,25 @@
|
||||
use axum::{
|
||||
extract::{Path, State, ConnectInfo},
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Redirect, Response, IntoResponse},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::utils::get_client_ip;
|
||||
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;
|
||||
|
||||
// GET /:code
|
||||
// Resolve and redirect
|
||||
pub async fn resolve_redirect(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(code): Path<String>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
@@ -31,43 +34,112 @@ pub async fn resolve_redirect(
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
match url_opt {
|
||||
Some(url) => {
|
||||
// Asynchronously record analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let referer = headers.get("referer")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Direct")
|
||||
.to_string();
|
||||
let accept_language = headers.get("accept-language")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
};
|
||||
|
||||
let record = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: url.id.clone(),
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
ip_address: ip,
|
||||
user_agent,
|
||||
referer,
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 302,
|
||||
};
|
||||
// 1. Expiration check
|
||||
if url.expired {
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
|
||||
// Push to memory queue (non-blocking)
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// Perform redirect
|
||||
Redirect::temporary(&url.destination).into_response()
|
||||
if let Some(ref expires_at_str) = url.expires_at {
|
||||
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
|
||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||
// Mark as expired in DB asynchronously/immediately
|
||||
{
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||
[url.id.clone()],
|
||||
);
|
||||
}
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
}
|
||||
|
||||
// 2. Access limit check
|
||||
if url.is_access_exhausted() {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This link has reached its maximum access limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 3. Password protection check
|
||||
if url.is_password_protected() {
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let authorized = jar
|
||||
.get(&cookie_name)
|
||||
.map(|c| c.value() == "authorized")
|
||||
.unwrap_or(false);
|
||||
|
||||
if !authorized {
|
||||
return Redirect::temporary(&format!("/gate/{}", code)).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Increment access count & retrieve preview config
|
||||
let _new_access_count = {
|
||||
let conn = state.db.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();
|
||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||
};
|
||||
|
||||
// Asynchronously record analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let referer = headers
|
||||
.get("referer")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Direct")
|
||||
.to_string();
|
||||
let accept_language = headers
|
||||
.get("accept-language")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let record = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: url.id.clone(),
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
ip_address: ip,
|
||||
user_agent,
|
||||
referer,
|
||||
accept_language,
|
||||
country,
|
||||
status_code: if preview_opt.is_some() { 200 } else { 302 },
|
||||
};
|
||||
|
||||
// Push to memory queue (non-blocking)
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// 5. Render Preview or Redirect
|
||||
if let Some(preview) = preview_opt {
|
||||
PreviewTemplate {
|
||||
code,
|
||||
title: preview.title,
|
||||
description: preview.description,
|
||||
logo_url: preview.logo_url,
|
||||
button_text: preview.button_text,
|
||||
destination: url.destination,
|
||||
}
|
||||
.into_response()
|
||||
} else {
|
||||
Redirect::temporary(&url.destination).into_response()
|
||||
}
|
||||
}
|
||||
+77
-21
@@ -1,9 +1,9 @@
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::web::{redirect, pages, admin, api, system};
|
||||
use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system};
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
@@ -11,16 +11,22 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.route("/:code", get(redirect::resolve_redirect))
|
||||
.route("/p/:code", get(pages::resolve_page))
|
||||
.route("/p/:code/*slug", get(pages::resolve_page))
|
||||
|
||||
.route(
|
||||
"/gate/:code",
|
||||
get(password_gate::gate_get).post(password_gate::gate_post),
|
||||
)
|
||||
// --- QR Code Serving ---
|
||||
.route("/api/qr/:file", get(qr::qr_handler))
|
||||
// --- System Health & Diagnostics ---
|
||||
.route("/status", get(system::status_endpoint))
|
||||
.route("/metrics", get(system::metrics_endpoint))
|
||||
|
||||
// --- Admin UI Login/Logout ---
|
||||
.route("/admin", get(admin::admin_index))
|
||||
.route("/admin/login", get(admin::login_get).post(admin::login_post))
|
||||
.route(
|
||||
"/admin/login",
|
||||
get(admin::login_get).post(admin::login_post),
|
||||
)
|
||||
.route("/admin/logout", get(admin::logout))
|
||||
|
||||
// --- Admin UI Pages ---
|
||||
.route("/admin/dashboard", get(admin::dashboard_get))
|
||||
.route("/admin/urls", get(admin::urls_get))
|
||||
@@ -30,26 +36,76 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.route("/admin/pages/create", post(admin::pages_create))
|
||||
.route("/admin/pages/delete/:id", post(admin::pages_delete))
|
||||
.route("/admin/settings", get(admin::settings_get))
|
||||
.route("/admin/settings/password", post(admin::change_password_post))
|
||||
.route("/admin/settings/retention", post(admin::change_retention_post))
|
||||
.route(
|
||||
"/admin/settings/password",
|
||||
post(admin::change_password_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/settings/retention",
|
||||
post(admin::change_retention_post),
|
||||
)
|
||||
.route("/admin/settings/compact", post(admin::compact_db_post))
|
||||
.route("/admin/settings/backup", get(admin::download_backup))
|
||||
.route("/admin/settings/api-keys/create", post(admin::create_api_key_post))
|
||||
.route("/admin/settings/api-keys/revoke/:id", post(admin::revoke_api_key_post))
|
||||
.route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post))
|
||||
.route(
|
||||
"/admin/settings/api-keys/create",
|
||||
post(admin::create_api_key_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/settings/api-keys/revoke/:id",
|
||||
post(admin::revoke_api_key_post),
|
||||
)
|
||||
.route("/admin/audit", get(admin::audit_get))
|
||||
.route("/admin/status", get(admin::status_get))
|
||||
|
||||
// --- REST API v1 JSON Endpoints ---
|
||||
.route("/api/v1/urls", post(api::api_create_url).get(api::api_list_urls))
|
||||
.route("/api/v1/urls/:uuid", get(api::api_get_url).put(api::api_update_url).delete(api::api_delete_url))
|
||||
.route("/api/v1/pages", post(api::api_create_page).get(api::api_list_pages))
|
||||
.route("/api/v1/pages/:uuid", get(api::api_get_page).put(api::api_update_page).delete(api::api_delete_page))
|
||||
.route(
|
||||
"/api/v1/urls",
|
||||
post(api::api_create_url).get(api::api_list_urls),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/urls/:uuid",
|
||||
get(api::api_get_url)
|
||||
.put(api::api_update_url)
|
||||
.delete(api::api_delete_url),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pages",
|
||||
post(api::api_create_page).get(api::api_list_pages),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pages/:uuid",
|
||||
get(api::api_get_page)
|
||||
.put(api::api_update_page)
|
||||
.delete(api::api_delete_page),
|
||||
)
|
||||
.route("/api/v1/stats", get(api::api_overall_stats))
|
||||
.route("/api/v1/stats/url/:uuid", get(api::api_url_stats))
|
||||
.route("/api/v1/stats/page/:uuid", get(api::api_page_stats))
|
||||
|
||||
// --- Feature Expansion REST API Endpoints ---
|
||||
.route("/api/v1/qr/:code", get(api::api_get_qr_stats))
|
||||
.route("/api/v1/bulk/qr", post(bulk::api_bulk_qr))
|
||||
.route("/api/v1/bulk/url", post(bulk::api_bulk_url))
|
||||
.route("/api/v1/audit", get(api::api_list_audit))
|
||||
// --- Feature 10 Non-Versioned API Extensions ---
|
||||
.route("/api/qr", post(api::api_create_qr))
|
||||
.route("/api/bulk/qr", post(bulk::api_bulk_qr))
|
||||
.route("/api/bulk/url", post(bulk::api_bulk_url))
|
||||
.route("/api/stats", get(api::api_overall_stats))
|
||||
.route("/api/audit", get(api::api_list_audit))
|
||||
.route(
|
||||
"/api/v1/urls/:uuid/preview",
|
||||
post(api::api_set_preview)
|
||||
.get(api::api_get_preview)
|
||||
.delete(api::api_delete_preview),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/urls/:uuid/password",
|
||||
post(api::api_set_password).delete(api::api_remove_password),
|
||||
)
|
||||
// --- Static Asset Stub ---
|
||||
.route("/static/style.css", get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }))
|
||||
|
||||
.route(
|
||||
"/static/style.css",
|
||||
get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
+16
-10
@@ -1,15 +1,15 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response, Json},
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::{authenticate_api_key, authenticate_session};
|
||||
use crate::db::admin::get_user_count;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_memory_usage, get_db_file_info};
|
||||
use crate::auth::{authenticate_session, authenticate_api_key};
|
||||
use crate::utils::{get_db_file_info, get_memory_usage};
|
||||
|
||||
// Helper: authenticate system request via header or session cookie
|
||||
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
|
||||
@@ -51,8 +51,9 @@ pub async fn status_endpoint(
|
||||
// Return public basic status for container/load-balancer health checks
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "application": "Healthy" }))
|
||||
).into_response();
|
||||
Json(serde_json::json!({ "application": "Healthy" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let is_db_ok = {
|
||||
@@ -61,7 +62,10 @@ pub async fn status_endpoint(
|
||||
};
|
||||
|
||||
let db_status = if is_db_ok {
|
||||
format!("Connected (WAL Mode enabled). Files Info:\n{}", get_db_file_info(&state.config.data_dir))
|
||||
format!(
|
||||
"Connected (WAL Mode enabled). Files Info:\n{}",
|
||||
get_db_file_info(&state.config.data_dir)
|
||||
)
|
||||
} else {
|
||||
"Disconnected".to_string()
|
||||
};
|
||||
@@ -71,12 +75,13 @@ pub async fn status_endpoint(
|
||||
Json(StatusResponse {
|
||||
application: "Healthy",
|
||||
database: db_status,
|
||||
queue_size: 0,
|
||||
queue_size: 0,
|
||||
memory_usage: get_memory_usage(),
|
||||
uptime_seconds: uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
}).into_response()
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// GET /metrics
|
||||
@@ -104,7 +109,7 @@ pub async fn metrics_endpoint(
|
||||
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)
|
||||
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -168,5 +173,6 @@ bzod_uptime_seconds {uptime}
|
||||
StatusCode::OK,
|
||||
[("Content-Type", "text/plain; version=0.0.4; charset=utf-8")],
|
||||
metrics_text,
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Reference in new issue
Block a user