AkurAI Build
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);
    }
}