Menu
Public repository / main
AkurAI-CRM / crates/crm-api/src/handlers/static_files.rs
Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects by Ólafur Búi Ólafsson
use crate::auth::{idp_client::urlencode, session};
use crate::router_setup::{internal_error, not_found, CrmState};
use akurai_http::{Request, Response};
use std::sync::{Arc, Mutex};
pub fn static_file_route(
state: Arc<Mutex<CrmState>>,
) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |req: &Request| {
let path = &req.path;
let state = match state.lock() {
Ok(s) => s,
Err(_) => return internal_error("lock poisoned"),
};
let frontend = &state.frontend_dir;
let file_path = if path == "/" {
frontend.join("index.html")
} else {
let clean = path.trim_start_matches('/');
frontend.join(clean)
};
let canonical = std::fs::canonicalize(&file_path).ok();
let frontend_canonical = std::fs::canonicalize(frontend)
.unwrap_or_else(|_| std::path::PathBuf::from("/nonexistent"));
let is_safe = canonical
.as_ref()
.is_some_and(|c| c.starts_with(&frontend_canonical));
if !is_safe {
return not_found("invalid path");
}
if !file_path.exists() || !file_path.is_file() {
let index = frontend.join("index.html");
return match std::fs::read_to_string(&index) {
Ok(body) => Response::ok()
.with_header("Content-Type", "text/html; charset=utf-8")
.with_body("text/html; charset=utf-8", body.into_bytes()),
Err(_) => not_found("file not found"),
};
}
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let public_page = file_path.file_name().and_then(|n| n.to_str()) == Some("index.html");
if ext == "html" && !public_page && !has_session(&state, req) {
return login_redirect(req);
}
let mime = match ext {
"html" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"json" => "application/json",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"woff2" => "font/woff2",
_ => "application/octet-stream",
};
match std::fs::read(&file_path) {
Ok(bytes) => Response::ok()
.with_header("Content-Type", mime)
.with_body(mime, bytes),
Err(_) => not_found("file not found"),
}
})
}
fn has_session(state: &CrmState, req: &Request) -> bool {
let Some(session_id) = session::extract_session_id(req) else {
return false;
};
state
.db
.lock()
.is_ok_and(|mut db| session::get_user(&mut db, &session_id).is_some())
}
/// Send an unauthenticated page load through login, then back to the page.
fn login_redirect(req: &Request) -> Response {
let target = match req.query.as_deref() {
Some(query) if !query.is_empty() => format!("{}?{query}", req.path),
_ => req.path.clone(),
};
Response::new(302)
.with_header(
"Location",
&format!("/auth/login?return_to={}", urlencode(&target)),
)
.with_header("Cache-Control", "no-store")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn state(name: &str) -> Arc<Mutex<CrmState>> {
let frontend = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../site/frontend"));
Arc::new(Mutex::new(
CrmState::new(&crate::auth::test_db_path(name), frontend).unwrap(),
))
}
fn get(state: &Arc<Mutex<CrmState>>, target: &str, cookie: &str) -> Response {
let head = format!("GET {target} HTTP/1.1\r\nCookie: {cookie}\r\n\r\n");
static_file_route(Arc::clone(state))(&Request::parse_head(head.as_bytes()).unwrap())
}
fn location(response: &Response) -> Option<&str> {
response
.headers
.iter()
.find(|(k, _)| k == "Location")
.map(|(_, v)| v.as_str())
}
#[test]
fn unauthenticated_app_page_redirects_to_login_with_return_to() {
let response = get(&state("static-anon"), "/people.html?id=4", "x=1");
assert_eq!(response.status, 302);
assert_eq!(
location(&response),
Some("/auth/login?return_to=%2Fpeople.html%3Fid%3D4")
);
}
#[test]
fn landing_page_and_assets_are_public() {
let state = state("static-public");
assert_eq!(get(&state, "/", "").status, 200);
assert_eq!(get(&state, "/styles.css", "").status, 200);
}
#[test]
fn authenticated_app_page_is_served() {
let state = state("static-auth");
let sid = {
let guard = state.lock().unwrap();
let mut db = guard.db.lock().unwrap();
session::create(&mut db, "u1", "e", "t").unwrap()
};
let response = get(&state, "/people.html", &format!("akurai_session={sid}"));
assert_eq!(response.status, 200);
}
}