use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use crate::engine::{EngineHandle, EngineMessage, EngineQuery, EngineResponse}; use crate::dto::{CssEntryDto, ImportCssRequest, LinkCssRequest, LogsParams, TimelineParams}; use crate::settings::{CssError, Settings}; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Json}; use axum::routing::{delete, get, post}; use axum::Router; use tokio::sync::oneshot; use tower_http::services::{ServeDir, ServeFile}; /// Shared, process-wide settings handle. Cheap to clone; lock is only held /// briefly since settings changes are rare (unlike the engine's hot path). pub type SettingsHandle = Arc>; #[derive(Clone)] struct ApiState { engine: EngineHandle, settings: SettingsHandle, /// Path to the settings JSON file on disk. Imported CSS files are stored /// alongside it, in its parent directory (see `ApiState::css_dir`). settings_path: Arc, } impl ApiState { fn css_dir(&self) -> PathBuf { self.settings_path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")) } } pub fn router( engine: EngineHandle, static_dir: &str, settings: SettingsHandle, settings_path: PathBuf, ) -> Router { let state = ApiState { engine, settings, settings_path: Arc::new(settings_path), }; let index_html = format!("{static_dir}/index.html"); let static_service = ServeDir::new(static_dir).fallback(ServeFile::new(index_html)); Router::new() .route("/api/logs", get(get_logs)) .route("/api/timeline", get(get_timeline)) .route("/api/stats", get(get_stats)) .route("/api/settings/css", get(list_css).post(import_css)) .route("/api/settings/css/link", post(link_css)) .route("/api/settings/css/{id}", delete(remove_css)) .route("/api/settings/css/{id}/content", get(get_css_content)) .with_state(state) .fallback_service(static_service) } async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result { let (respond_to, response) = oneshot::channel(); engine .send(EngineMessage::Query { query, respond_to }) .await .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE) } async fn get_logs( State(state): State, Query(params): Query, ) -> impl IntoResponse { match ask(&state.engine, EngineQuery::Logs(params)).await { Ok(EngineResponse::Logs(entries)) => Json(entries).into_response(), Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Err(status) => status.into_response(), } } async fn get_timeline( State(state): State, Query(params): Query, ) -> impl IntoResponse { match ask(&state.engine, EngineQuery::Timeline(params)).await { Ok(EngineResponse::Timeline(buckets)) => Json(buckets).into_response(), Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Err(status) => status.into_response(), } } async fn get_stats(State(state): State) -> impl IntoResponse { match ask(&state.engine, EngineQuery::Stats).await { Ok(EngineResponse::Stats(stats)) => Json(stats).into_response(), Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Err(status) => status.into_response(), } } /// Maps a `CssError` from a settings mutation/lookup to a response status /// and message: `NotFound` -> 404, `NotImported` -> 400 (the id is valid but /// refers to a linked entry, which this operation doesn't apply to), `Io` -> /// 500. fn css_error_response(err: CssError) -> axum::response::Response { match err { CssError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), CssError::NotImported => (StatusCode::BAD_REQUEST, err.to_string()).into_response(), CssError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), } } /// `GET /api/settings/css` — lists all configured CSS entries (imported and /// linked) as `Vec`. async fn list_css(State(state): State) -> impl IntoResponse { let settings = state.settings.lock().unwrap(); let entries: Vec = settings.css_entries.iter().map(CssEntryDto::from).collect(); Json(entries).into_response() } /// `POST /api/settings/css` — imports raw CSS content. Body: /// `{ "filename": "theme.css", "content": "body { ... }" }`. Stores the /// content as a file alongside the settings file, adds a `CssEntry::Imported` /// to settings, and returns the new entry as `CssEntryDto` (201 Created). async fn import_css( State(state): State, Json(body): Json, ) -> impl IntoResponse { let css_dir = state.css_dir(); let mut settings = state.settings.lock().unwrap(); match settings.import_css(&state.settings_path, &css_dir, body.filename, &body.content) { Ok(id) => { let entry = settings .css_entries .iter() .find(|entry| crate::settings::css_entry_id(entry) == id) .expect("entry was just inserted by import_css"); (StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response() } Err(err) => css_error_response(err), } } /// `POST /api/settings/css/link` — registers a reference to an external CSS /// path/URL. Body: `{ "path_or_url": "https://example.com/style.css" }`. /// Content is never copied; the entry is never deletable via /// `DELETE /api/settings/css/:id`. Returns the new entry as `CssEntryDto` /// (201 Created). async fn link_css( State(state): State, Json(body): Json, ) -> impl IntoResponse { let mut settings = state.settings.lock().unwrap(); match settings.link_css(&state.settings_path, body.path_or_url.clone()) { Ok(()) => { let entry = settings .css_entries .last() .expect("entry was just pushed by link_css"); (StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response() } Err(err) => css_error_response(err), } } /// `DELETE /api/settings/css/:id` — removes an imported CSS entry (file + /// settings reference). Rejects with 400 if `id` refers to a `Linked` entry /// (never deletable this way) and 404 if `id` is unknown. On success returns /// 204 No Content. async fn remove_css( State(state): State, AxumPath(id): AxumPath, ) -> impl IntoResponse { let css_dir = state.css_dir(); let mut settings = state.settings.lock().unwrap(); match settings.remove_css(&state.settings_path, &css_dir, &id) { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(err) => css_error_response(err), } } /// `GET /api/settings/css/:id/content` — serves back the raw CSS content of /// an imported entry (`content-type: text/css`). Rejects with 400 for a /// linked entry (there's no local content to serve — the frontend should use /// `path_or_url` directly) and 404 for an unknown id. async fn get_css_content( State(state): State, AxumPath(id): AxumPath, ) -> impl IntoResponse { let css_dir = state.css_dir(); let settings = state.settings.lock().unwrap(); match settings.css_content(&css_dir, &id) { Ok(content) => ( StatusCode::OK, [(header::CONTENT_TYPE, "text/css; charset=utf-8")], content, ) .into_response(), Err(err) => css_error_response(err), } }