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
+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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user