189 lines
6.3 KiB
Rust
189 lines
6.3 KiB
Rust
use axum::{
|
|
extract::{ConnectInfo, Path, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::{Html, IntoResponse, Response},
|
|
};
|
|
use chrono::Utc;
|
|
use rusqlite::OptionalExtension;
|
|
use std::net::SocketAddr;
|
|
use uuid::Uuid;
|
|
|
|
use crate::analytics::get_client_country;
|
|
use crate::models::VisitRecord;
|
|
use crate::state::AppState;
|
|
use crate::utils::get_client_ip;
|
|
|
|
// 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 !crate::utils::validation::validate_page_code(&code) {
|
|
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
|
}
|
|
|
|
// 1. Query global slug namespace in system.db
|
|
let slug_info = {
|
|
let system_conn = state.system_db.lock().unwrap();
|
|
let mut stmt = match system_conn.prepare(
|
|
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
|
) {
|
|
Ok(s) => s,
|
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
};
|
|
stmt.query_row(rusqlite::params![code], |row| {
|
|
Ok((
|
|
row.get::<_, i64>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
row.get::<_, String>(2)?,
|
|
row.get::<_, String>(3)?,
|
|
))
|
|
})
|
|
.optional()
|
|
};
|
|
|
|
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
|
Ok(Some(info)) => info,
|
|
Ok(None) => {
|
|
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
|
(1, "page".to_string(), "".to_string(), "active".to_string())
|
|
}
|
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
};
|
|
|
|
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
|
if slug_status != "active" {
|
|
return (
|
|
StatusCode::GONE,
|
|
"This content has been disabled or moderated",
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
|
|
// tenant users use per-user content databases
|
|
let content_conn = if owner_user_id == 1 {
|
|
state.content_db.clone()
|
|
} else {
|
|
match state.get_user_dbs(owner_user_id) {
|
|
Ok(dbs) => dbs.content,
|
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
}
|
|
};
|
|
|
|
let page_opt = {
|
|
let conn = content_conn.lock().unwrap();
|
|
match crate::db::content::get_landing_page_by_code(&conn, &code) {
|
|
Ok(page) => page,
|
|
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
|
}
|
|
};
|
|
|
|
match page_opt {
|
|
Some(page) => {
|
|
// Check state
|
|
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,
|
|
owner_user_id: Some(owner_user_id),
|
|
};
|
|
|
|
state.analytics_queue.push(record);
|
|
|
|
// Render raw HTML
|
|
Html(page.html_content).into_response()
|
|
}
|
|
None => (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
|
}
|
|
}
|
|
|
|
// GET /
|
|
// Serve static root landing page from www/index.html
|
|
pub async fn root_landing() -> Response {
|
|
let mut target_path = std::path::PathBuf::from("www/index.html");
|
|
|
|
if !target_path.exists() {
|
|
// Search relative to executable
|
|
if let Ok(exe_path) = std::env::current_exe() {
|
|
if let Some(exe_dir) = exe_path.parent() {
|
|
let path1 = exe_dir.join("www/index.html");
|
|
if path1.exists() {
|
|
target_path = path1;
|
|
} else if let Some(parent1) = exe_dir.parent() {
|
|
let path2 = parent1.join("www/index.html");
|
|
if path2.exists() {
|
|
target_path = path2;
|
|
} else if let Some(parent2) = parent1.parent() {
|
|
let path3 = parent2.join("www/index.html");
|
|
if path3.exists() {
|
|
target_path = path3;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !target_path.exists() {
|
|
// Search in CARGO_MANIFEST_DIR
|
|
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
|
let path = std::path::PathBuf::from(manifest_dir).join("www/index.html");
|
|
if path.exists() {
|
|
target_path = path;
|
|
}
|
|
}
|
|
}
|
|
|
|
match std::fs::read_to_string(&target_path) {
|
|
Ok(content) => Html(content).into_response(),
|
|
Err(_) => (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn deploy_script() -> Response {
|
|
match tokio::fs::read("www/deploy.sh").await {
|
|
Ok(content) => (
|
|
StatusCode::OK,
|
|
[("content-type", "text/plain; charset=utf-8")],
|
|
content,
|
|
)
|
|
.into_response(),
|
|
|
|
Err(_) => (StatusCode::NOT_FOUND, "deploy.sh not found").into_response(),
|
|
}
|
|
}
|