Initial public release
This commit is contained in:
commit
c1107147b4
92 files changed
+10562
No files matched your search
@@ -0,0 +1,911 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query, ConnectInfo},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Redirect, Response, IntoResponse},
|
||||
Form,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use chrono::Utc;
|
||||
use tar::Builder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
|
||||
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
|
||||
};
|
||||
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
|
||||
};
|
||||
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};
|
||||
|
||||
// Helper: Verify session and return user or redirect to login
|
||||
async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match authenticate_session(&conn, jar) {
|
||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||
_ => Err(Redirect::to("/admin/login")),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/login
|
||||
pub async fn login_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> 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()))
|
||||
.path("/admin/login")
|
||||
.secure(state.config.cookie_secure)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.build()
|
||||
);
|
||||
|
||||
let template = crate::templates::LoginTemplate { error, csrf_token };
|
||||
(new_jar, template).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/login
|
||||
pub async fn login_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
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();
|
||||
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
|
||||
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let user_count = {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
get_user_count(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
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) {
|
||||
let hash = match hash_password(&form.password) {
|
||||
Ok(h) => h,
|
||||
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()));
|
||||
Some(u)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
// Standard DB Verification
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match get_user_by_username(&conn, &form.username) {
|
||||
Ok(Some(u)) => {
|
||||
if verify_password(&form.password, &u.password_hash) {
|
||||
Some(u)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
match user_opt {
|
||||
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 cookie = Cookie::build(("bzod_session", session_token))
|
||||
.path("/")
|
||||
.secure(state.config.cookie_secure)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::days(30))
|
||||
.build();
|
||||
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/admin/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||
}
|
||||
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()));
|
||||
}
|
||||
Redirect::to("/admin/login?error=Invalid username or password").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/logout
|
||||
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);
|
||||
}
|
||||
|
||||
let cookie = Cookie::build("bzod_session")
|
||||
.path("/")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie);
|
||||
|
||||
(response_jar, Redirect::to("/admin/login")).into_response()
|
||||
}
|
||||
|
||||
// GET /admin/dashboard
|
||||
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(),
|
||||
};
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let total_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_total_clicks(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let clicks_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_clicks_trend(&conn, "url", "all", 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let 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();
|
||||
trend_map.insert(date_str, 0i64);
|
||||
}
|
||||
for (d, c) in clicks_data {
|
||||
trend_map.insert(d, c);
|
||||
}
|
||||
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||
|
||||
let countries_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let countries_chart = generate_bar_chart(&countries_data);
|
||||
|
||||
let referrers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||
|
||||
let browsers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||
|
||||
let template = crate::templates::DashboardTemplate {
|
||||
admin_username: user.username,
|
||||
total_urls,
|
||||
total_pages,
|
||||
total_clicks,
|
||||
active_links,
|
||||
dead_links,
|
||||
traffic_chart,
|
||||
countries_chart,
|
||||
browsers_chart,
|
||||
referrers_chart,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /admin/urls
|
||||
#[derive(Deserialize)]
|
||||
pub struct UrlsQuery {
|
||||
pub tag: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn urls_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UrlsQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let urls = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
list_urls(&conn, 100, 0, query.tag.as_deref()).unwrap_or_default()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UrlsTemplate {
|
||||
admin_username: user.username,
|
||||
urls,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
tag_filter: query.tag,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUrlForm {
|
||||
pub destination: String,
|
||||
pub code: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub tags: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/urls/create
|
||||
pub async fn urls_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUrlForm>,
|
||||
) -> 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/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let mut code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to("/admin/urls?error=Custom code must be exactly 6 hex characters").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
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 res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_url(&conn, &code, &form.destination, title_opt, desc_opt, &tags_list)
|
||||
};
|
||||
|
||||
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()));
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/urls/delete/:id
|
||||
pub async fn urls_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn_admin, &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(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/pages
|
||||
#[derive(Deserialize)]
|
||||
pub struct PagesQuery {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn pages_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<PagesQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
list_landing_pages(&conn, 100, 0).unwrap_or_default()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::PagesTemplate {
|
||||
admin_username: user.username,
|
||||
pages,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePageForm {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
pub html_content: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/pages/create
|
||||
pub async fn pages_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreatePageForm>,
|
||||
) -> 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/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let mut code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(2);
|
||||
} else {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to("/admin/pages?error=Custom code must be exactly 4 hex characters").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let clean_slug = form.slug.trim().to_lowercase();
|
||||
if clean_slug.is_empty() {
|
||||
return Redirect::to("/admin/pages?error=Slug is required").into_response();
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_landing_page(&conn, &code, &clean_slug, &form.title, &form.html_content, &form.state)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let 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()));
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||
Redirect::to("/admin/pages?error=Short code already exists").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/pages/delete/:id
|
||||
pub async fn pages_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(&conn_admin, &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(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/settings
|
||||
#[derive(Deserialize)]
|
||||
pub struct SettingsQuery {
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn settings_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<SettingsQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let api_keys = {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
list_api_keys(&conn, &user.id).unwrap_or_default()
|
||||
};
|
||||
|
||||
let data_retention = {
|
||||
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()))
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::SettingsTemplate {
|
||||
admin_username: user.username,
|
||||
api_keys,
|
||||
data_retention,
|
||||
csrf_token,
|
||||
success: query.success,
|
||||
error: query.error,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangePasswordForm {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/settings/password
|
||||
pub async fn change_password_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<ChangePasswordForm>,
|
||||
) -> 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 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()));
|
||||
return Redirect::to("/admin/settings?error=Incorrect current password").into_response();
|
||||
}
|
||||
|
||||
let new_hash = match hash_password(&form.new_password) {
|
||||
Ok(h) => h,
|
||||
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]);
|
||||
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()));
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RetentionForm {
|
||||
pub retention: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/settings/retention
|
||||
pub async fn change_retention_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<RetentionForm>,
|
||||
) -> 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 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()));
|
||||
Redirect::to("/admin/settings?success=Retention policy saved").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/settings/compact
|
||||
pub async fn compact_db_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Response {
|
||||
let (user, _session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
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()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/settings/backup
|
||||
pub async fn download_backup(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Response {
|
||||
let (user, _) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
// Create tar.gz in memory
|
||||
let mut buffer = Vec::new();
|
||||
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 {
|
||||
let path = state.config.data_dir.join(f);
|
||||
if path.exists() {
|
||||
if let Err(e) = tar.append_path_with_name(&path, f) {
|
||||
add_err = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match add_err {
|
||||
Some(e) => Err(e),
|
||||
None => {
|
||||
match tar.into_inner().and_then(|encoder| encoder.finish()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match res {
|
||||
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 date_str = Utc::now().format("%Y-%m-%d").to_string();
|
||||
let filename = format!("{}-bzod-backup.tar.gz", date_str);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "application/gzip"),
|
||||
("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)),
|
||||
],
|
||||
buffer,
|
||||
).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateApiKeyForm {
|
||||
pub key_name: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/settings/api-keys/create
|
||||
pub async fn create_api_key_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateApiKeyForm>,
|
||||
) -> 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 key_secret = format!("bzo_{}", generate_token(16));
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_secret.as_bytes());
|
||||
let hashed_key = hex::encode(hasher.finalize());
|
||||
|
||||
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()));
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/settings/api-keys/revoke/:id
|
||||
pub async fn revoke_api_key_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
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()));
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/audit
|
||||
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 template = crate::templates::AuditTemplate {
|
||||
admin_username: user.username,
|
||||
logs,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /admin/status
|
||||
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))
|
||||
} else {
|
||||
"Degraded (Database connections failed)".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
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 template = crate::templates::StatusTemplate {
|
||||
admin_username: user.username,
|
||||
app_status,
|
||||
db_status,
|
||||
queue_size,
|
||||
memory_usage,
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query, ConnectInfo},
|
||||
http::{StatusCode, HeaderMap},
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::db::content::{
|
||||
list_urls, get_url_by_id, create_url, update_url, delete_url,
|
||||
list_landing_pages, get_landing_page_by_id, create_landing_page, update_landing_page, delete_landing_page,
|
||||
get_url_counts, get_landing_page_count
|
||||
};
|
||||
use crate::db::analytics::{
|
||||
get_total_clicks, get_total_page_views, get_clicks_trend, get_clicks_trend_raw,
|
||||
get_metric_rankings, get_metric_rankings_raw
|
||||
};
|
||||
use crate::db::admin::write_audit_log;
|
||||
use crate::utils::get_client_ip;
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::ApiUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
// JSON Payload Structs
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUrlRequest {
|
||||
pub destination: String,
|
||||
pub code: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUrlRequest {
|
||||
pub destination: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub status: String, // 'healthy', 'suspect', 'dead'
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePageRequest {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub html_content: String,
|
||||
pub state: String, // 'draft', 'published', 'archived'
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdatePageRequest {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub html_content: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
// Error JSON Response
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiError {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
// --- URL Endpoints ---
|
||||
|
||||
// POST /api/v1/urls
|
||||
pub async fn api_create_url(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<CreateUrlRequest>,
|
||||
) -> Response {
|
||||
let mut code = payload.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()) {
|
||||
return (StatusCode::BAD_REQUEST, Json(ApiError { error: "Short code must be 6 hex characters".to_string() })).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let tags = payload.tags.unwrap_or_default();
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match create_url(&conn, &code, &payload.destination, payload.title.as_deref(), payload.description.as_deref(), &tags) {
|
||||
Ok(url) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), user_agent);
|
||||
(StatusCode::CREATED, Json(url)).into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||
(StatusCode::CONFLICT, Json(ApiError { error: "Short code already exists".to_string() })).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/urls
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
pub tag: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn api_list_urls(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Response {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match list_urls(&conn, limit, offset, query.tag.as_deref()) {
|
||||
Ok(urls) => Json(urls).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/urls/:uuid
|
||||
pub async fn api_get_url(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match get_url_by_id(&conn, &uuid) {
|
||||
Ok(Some(url)) => Json(url).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/v1/urls/:uuid
|
||||
pub async fn api_update_url(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<UpdateUrlRequest>,
|
||||
) -> Response {
|
||||
let tags = payload.tags.unwrap_or_default();
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match update_url(&conn, &uuid, &payload.destination, payload.title.as_deref(), payload.description.as_deref(), &payload.status, &tags) {
|
||||
Ok(Some(url)) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_UPDATE", Some("url"), Some(&uuid), Some(&ip), user_agent);
|
||||
Json(url).into_response()
|
||||
}
|
||||
Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/urls/:uuid
|
||||
pub async fn api_delete_url(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_url(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_DELETION", Some("url"), Some(&uuid), Some(&ip), user_agent);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Ok(false) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Landing Page Endpoints ---
|
||||
|
||||
// POST /api/v1/pages
|
||||
pub async fn api_create_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<CreatePageRequest>,
|
||||
) -> Response {
|
||||
let mut code = payload.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(2); // 4 hex
|
||||
} else {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return (StatusCode::BAD_REQUEST, Json(ApiError { error: "Short code must be 4 hex characters".to_string() })).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match create_landing_page(&conn, &code, &payload.slug, &payload.title, &payload.html_content, &payload.state) {
|
||||
Ok(page) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), user_agent);
|
||||
(StatusCode::CREATED, Json(page)).into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||
(StatusCode::CONFLICT, Json(ApiError { error: "Short code already exists".to_string() })).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/pages
|
||||
pub async fn api_list_pages(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Response {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match list_landing_pages(&conn, limit, offset) {
|
||||
Ok(pages) => Json(pages).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/pages/:uuid
|
||||
pub async fn api_get_page(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &uuid) {
|
||||
Ok(Some(page)) => Json(page).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/v1/pages/:uuid
|
||||
pub async fn api_update_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<UpdatePageRequest>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match update_landing_page(&conn, &uuid, &payload.slug, &payload.title, &payload.html_content, &payload.state) {
|
||||
Ok(Some(page)) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_UPDATE", Some("page"), Some(&uuid), Some(&ip), user_agent);
|
||||
Json(page).into_response()
|
||||
}
|
||||
Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/pages/:uuid
|
||||
pub async fn api_delete_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_landing_page(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_DELETION", Some("page"), Some(&uuid), Some(&ip), user_agent);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Ok(false) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Statistics Endpoints ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OverallStatsResponse {
|
||||
pub total_urls: i64,
|
||||
pub total_pages: i64,
|
||||
pub total_clicks: i64,
|
||||
pub total_page_views: i64,
|
||||
pub active_links: i64,
|
||||
pub dead_links: i64,
|
||||
}
|
||||
|
||||
// GET /api/v1/stats
|
||||
pub async fn api_overall_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
) -> Response {
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
};
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
let (total_clicks, total_page_views) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
get_total_clicks(&conn).unwrap_or(0),
|
||||
get_total_page_views(&conn).unwrap_or(0)
|
||||
)
|
||||
};
|
||||
|
||||
Json(OverallStatsResponse {
|
||||
total_urls,
|
||||
total_pages,
|
||||
total_clicks,
|
||||
total_page_views,
|
||||
active_links,
|
||||
dead_links,
|
||||
}).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DetailStatsResponse {
|
||||
pub target_id: String,
|
||||
pub clicks: Vec<(String, i64)>,
|
||||
pub top_countries: Vec<(String, i64)>,
|
||||
pub top_referrers: Vec<(String, i64)>,
|
||||
pub top_browsers: Vec<(String, i64)>,
|
||||
}
|
||||
|
||||
// GET /api/v1/stats/url/:uuid
|
||||
pub async fn api_url_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let clicks = get_clicks_trend(&conn, "url", &uuid, 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30))
|
||||
.unwrap_or_default();
|
||||
let top_countries = get_metric_rankings(&conn, "url", &uuid, "country", 10)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "country", 10))
|
||||
.unwrap_or_default();
|
||||
let top_referrers = get_metric_rankings(&conn, "url", &uuid, "referrer", 10)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "referrer", 10))
|
||||
.unwrap_or_default();
|
||||
let top_browsers = get_metric_rankings(&conn, "url", &uuid, "browser", 10)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "browser", 10))
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(DetailStatsResponse {
|
||||
target_id: uuid,
|
||||
clicks,
|
||||
top_countries,
|
||||
top_referrers,
|
||||
top_browsers,
|
||||
}).into_response()
|
||||
}
|
||||
|
||||
// GET /api/v1/stats/page/:uuid
|
||||
pub async fn api_page_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
// Verify Page exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
if get_landing_page_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let clicks = get_clicks_trend(&conn, "page", &uuid, 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30))
|
||||
.unwrap_or_default();
|
||||
let top_countries = get_metric_rankings(&conn, "page", &uuid, "country", 10)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "country", 10))
|
||||
.unwrap_or_default();
|
||||
let top_referrers = get_metric_rankings(&conn, "page", &uuid, "referrer", 10)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "referrer", 10))
|
||||
.unwrap_or_default();
|
||||
let top_browsers = get_metric_rankings(&conn, "page", &uuid, "browser", 10)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "browser", 10))
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(DetailStatsResponse {
|
||||
target_id: uuid,
|
||||
clicks,
|
||||
top_countries,
|
||||
top_referrers,
|
||||
top_browsers,
|
||||
}).into_response()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// General web middleware placeholder
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod routes;
|
||||
pub mod middleware;
|
||||
pub mod redirect;
|
||||
pub mod pages;
|
||||
pub mod admin;
|
||||
pub mod api;
|
||||
pub mod system;
|
||||
|
||||
pub use routes::create_router;
|
||||
@@ -0,0 +1,76 @@
|
||||
use axum::{
|
||||
extract::{Path, State, ConnectInfo},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Response, Html, IntoResponse},
|
||||
};
|
||||
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::services::landing_pages::get_landing_page_by_code;
|
||||
|
||||
// GET /p/:code and GET /p/:code/*slug
|
||||
// Resolve and render landing page
|
||||
pub async fn resolve_page(
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Response {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let page_opt = match get_landing_page_by_code(&state.db, &code) {
|
||||
Ok(page) => page,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
match page_opt {
|
||||
Some(page) => {
|
||||
// Check state
|
||||
if page.state == "archived" {
|
||||
return (StatusCode::GONE, "This landing page has been archived").into_response();
|
||||
}
|
||||
|
||||
// Record view 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: "page".to_string(),
|
||||
target_id: page.id.clone(),
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
ip_address: ip,
|
||||
user_agent,
|
||||
referer,
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 200,
|
||||
};
|
||||
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// Render raw HTML
|
||||
Html(page.html_content).into_response()
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use axum::{
|
||||
extract::{Path, State, ConnectInfo},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Redirect, Response, IntoResponse},
|
||||
};
|
||||
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::services::shortener::get_url_by_code;
|
||||
|
||||
// GET /:code
|
||||
// Resolve and redirect
|
||||
pub async fn resolve_redirect(
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Response {
|
||||
// Basic validation of code (must be 6 hex characters)
|
||||
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(url) => url,
|
||||
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 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,
|
||||
};
|
||||
|
||||
// Push to memory queue (non-blocking)
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// Perform redirect
|
||||
Redirect::temporary(&url.destination).into_response()
|
||||
}
|
||||
None => (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::web::{redirect, pages, admin, api, system};
|
||||
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
// --- Public Redirection Routes ---
|
||||
.route("/:code", get(redirect::resolve_redirect))
|
||||
.route("/p/:code", get(pages::resolve_page))
|
||||
.route("/p/:code/*slug", get(pages::resolve_page))
|
||||
|
||||
// --- 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/logout", get(admin::logout))
|
||||
|
||||
// --- Admin UI Pages ---
|
||||
.route("/admin/dashboard", get(admin::dashboard_get))
|
||||
.route("/admin/urls", get(admin::urls_get))
|
||||
.route("/admin/urls/create", post(admin::urls_create))
|
||||
.route("/admin/urls/delete/:id", post(admin::urls_delete))
|
||||
.route("/admin/pages", get(admin::pages_get))
|
||||
.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/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/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/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))
|
||||
|
||||
// --- Static Asset Stub ---
|
||||
.route("/static/style.css", get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }))
|
||||
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response, Json},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::Serialize;
|
||||
|
||||
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};
|
||||
|
||||
// Helper: authenticate system request via header or session cookie
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try cookie session
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_session(&conn, jar) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StatusResponse {
|
||||
pub application: &'static str,
|
||||
pub database: String,
|
||||
pub queue_size: usize,
|
||||
pub memory_usage: String,
|
||||
pub uptime_seconds: u64,
|
||||
pub version: &'static str,
|
||||
pub git_commit: &'static str,
|
||||
}
|
||||
|
||||
// GET /status
|
||||
pub async fn status_endpoint(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if !authenticate_request(&state, &jar, &headers) {
|
||||
// Return public basic status for container/load-balancer health checks
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "application": "Healthy" }))
|
||||
).into_response();
|
||||
}
|
||||
|
||||
let is_db_ok = {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
get_user_count(&conn).is_ok()
|
||||
};
|
||||
|
||||
let db_status = if is_db_ok {
|
||||
format!("Connected (WAL Mode enabled). Files Info:\n{}", get_db_file_info(&state.config.data_dir))
|
||||
} else {
|
||||
"Disconnected".to_string()
|
||||
};
|
||||
|
||||
let uptime = state.start_time.elapsed().as_secs();
|
||||
|
||||
Json(StatusResponse {
|
||||
application: "Healthy",
|
||||
database: db_status,
|
||||
queue_size: 0,
|
||||
memory_usage: get_memory_usage(),
|
||||
uptime_seconds: uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
}).into_response()
|
||||
}
|
||||
|
||||
// GET /metrics
|
||||
pub async fn metrics_endpoint(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if !authenticate_request(&state, &jar, &headers) {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
// 1. Gather stats from DBs
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let (total_clicks, total_page_views) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
|
||||
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0)
|
||||
)
|
||||
};
|
||||
|
||||
// Calculate memory in bytes
|
||||
let mut mem_bytes = 0;
|
||||
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
|
||||
if let Some(pages_str) = statm.split_whitespace().next() {
|
||||
if let Ok(pages) = pages_str.parse::<u64>() {
|
||||
mem_bytes = pages * 4096;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let uptime = state.start_time.elapsed().as_secs();
|
||||
|
||||
// 2. Format as Prometheus metrics text
|
||||
let metrics_text = format!(
|
||||
r#"# HELP bzod_urls_total Total number of registered short URLs
|
||||
# TYPE bzod_urls_total gauge
|
||||
bzod_urls_total {total_urls}
|
||||
|
||||
# HELP bzod_active_urls Total number of active/healthy short URLs
|
||||
# TYPE bzod_active_urls gauge
|
||||
bzod_active_urls {active_links}
|
||||
|
||||
# HELP bzod_dead_urls Total number of dead short URLs
|
||||
# TYPE bzod_dead_urls gauge
|
||||
bzod_dead_urls {dead_links}
|
||||
|
||||
# HELP bzod_pages_total Total number of registered landing pages
|
||||
# TYPE bzod_pages_total gauge
|
||||
bzod_pages_total {total_pages}
|
||||
|
||||
# HELP bzod_clicks_total Total number of URL clicks recorded
|
||||
# TYPE bzod_clicks_total counter
|
||||
bzod_clicks_total {total_clicks}
|
||||
|
||||
# HELP bzod_page_views_total Total number of page views recorded
|
||||
# TYPE bzod_page_views_total counter
|
||||
bzod_page_views_total {total_page_views}
|
||||
|
||||
# HELP bzod_memory_bytes Memory usage of the bzod process in bytes
|
||||
# TYPE bzod_memory_bytes gauge
|
||||
bzod_memory_bytes {mem_bytes}
|
||||
|
||||
# HELP bzod_uptime_seconds Uptime of the bzod process in seconds
|
||||
# TYPE bzod_uptime_seconds counter
|
||||
bzod_uptime_seconds {uptime}
|
||||
"#,
|
||||
total_urls = total_urls,
|
||||
active_links = active_links,
|
||||
dead_links = dead_links,
|
||||
total_pages = total_pages,
|
||||
total_clicks = total_clicks,
|
||||
total_page_views = total_page_views,
|
||||
mem_bytes = mem_bytes,
|
||||
uptime = uptime
|
||||
);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("Content-Type", "text/plain; version=0.0.4; charset=utf-8")],
|
||||
metrics_text,
|
||||
).into_response()
|
||||
}
|
||||
Reference in new issue
Block a user