From 71aaa4ebb46bc0d9f12c1fc38db5b238eb776b20 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 12:51:22 +0100 Subject: [PATCH] Add CSS import/link/list/delete/content API (ticket 010) Wires up Settings.css_entries behind a small settings API: import raw CSS content to a file stored alongside settings.json, link external CSS paths/URLs by reference (never deletable/copied), list all entries, delete imported entries, and serve back imported content. CSS storage logic lives on Settings (import_css/link_css/remove_css/css_content) with api.rs handlers staying thin, matching the existing engine-query pattern. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- src/api.rs | 144 ++++++++++++++++++++++++-- src/dto.rs | 56 ++++++++++ src/main.rs | 2 +- src/settings.rs | 265 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 457 insertions(+), 10 deletions(-) diff --git a/src/api.rs b/src/api.rs index dde0600..0e69fb1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,12 +1,13 @@ +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use crate::engine::{EngineHandle, EngineMessage, EngineQuery, EngineResponse}; -use crate::dto::{LogsParams, TimelineParams}; -use crate::settings::Settings; -use axum::extract::{Query, State}; -use axum::http::StatusCode; +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::get; +use axum::routing::{delete, get, post}; use axum::Router; use tokio::sync::oneshot; use tower_http::services::{ServeDir, ServeFile}; @@ -18,13 +19,32 @@ pub type SettingsHandle = Arc>; #[derive(Clone)] struct ApiState { engine: EngineHandle, - // TODO(ticket 010/011): wire up settings/CSS/NavMenu endpoints using this. - #[allow(dead_code)] 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, } -pub fn router(engine: EngineHandle, static_dir: &str, settings: SettingsHandle) -> Router { - let state = ApiState { engine, settings }; +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)); @@ -32,6 +52,10 @@ pub fn router(engine: EngineHandle, static_dir: &str, settings: SettingsHandle) .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) } @@ -74,3 +98,105 @@ async fn get_stats(State(state): State) -> impl IntoResponse { 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), + } +} diff --git a/src/dto.rs b/src/dto.rs index 6780771..38d6032 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -1,4 +1,5 @@ use crate::data_value::MetricPrimitiveKind; +use crate::settings::CssEntry; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize)] @@ -45,3 +46,58 @@ pub struct TimelineParams { pub end_ms: Option, pub bucket_ms: Option, } + +/// Request body for `POST /api/settings/css`: raw CSS content to import and +/// store alongside the settings file. +#[derive(Debug, Deserialize)] +pub struct ImportCssRequest { + pub filename: String, + pub content: String, +} + +/// Request body for `POST /api/settings/css/link`: a reference to an +/// external CSS path/URL that ottyl doesn't copy or own. +#[derive(Debug, Deserialize)] +pub struct LinkCssRequest { + pub path_or_url: String, +} + +/// Response shape for a single CSS entry, used both by the list endpoint and +/// by mutation endpoints that return the affected entry. `id` is the entry's +/// real id for `Imported` entries, or a stable id derived from the +/// linked path/URL for `Linked` entries (see `settings::css_entry_id`) — +/// this lets the frontend key/reference both kinds of entries uniformly, +/// even though only `Imported` entries are deletable/content-fetchable. +#[derive(Debug, Clone, Serialize)] +pub struct CssEntryDto { + pub id: String, + pub kind: CssEntryKind, + pub filename: Option, + pub path_or_url: Option, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CssEntryKind { + Imported, + Linked, +} + +impl From<&CssEntry> for CssEntryDto { + fn from(entry: &CssEntry) -> Self { + match entry { + CssEntry::Imported { id, filename } => CssEntryDto { + id: id.clone(), + kind: CssEntryKind::Imported, + filename: Some(filename.clone()), + path_or_url: None, + }, + CssEntry::Linked { path_or_url } => CssEntryDto { + id: crate::settings::css_entry_id(entry), + kind: CssEntryKind::Linked, + filename: None, + path_or_url: Some(path_or_url.clone()), + }, + } + } +} diff --git a/src/main.rs b/src/main.rs index 5f46b3e..63b3199 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,7 +50,7 @@ async fn main() { tokio::spawn(engine::run(engine_rx)); tokio::spawn(ingest::run(engine_tx.clone())); - let app = api::router(engine_tx, &args.static_dir, settings); + let app = api::router(engine_tx, &args.static_dir, settings, settings_path); let listener = TcpListener::bind(("0.0.0.0", args.port)) .await .expect("failed to bind listener"); diff --git a/src/settings.rs b/src/settings.rs index b8be1fa..eae107e 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -5,9 +5,13 @@ //! Persistence format is JSON (via `serde_json`, already a dependency) rather //! than TOML, to keep the dependency list minimal. +use std::collections::hash_map::DefaultHasher; +use std::fmt; use std::fs; +use std::hash::{Hash, Hasher}; use std::io; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; @@ -134,6 +138,166 @@ impl Settings { } } +/// Error returned by CSS-entry mutations and lookups (ticket 010). +#[derive(Debug)] +pub enum CssError { + /// No entry with the given id exists. + NotFound, + /// The entry exists but is a `Linked` entry, not an `Imported` one, so + /// the requested operation (delete / read-content) doesn't apply: ottyl + /// never owns a linked entry's content, so there's nothing to delete or + /// serve back. + NotImported, + /// Underlying I/O failure: writing/reading/removing the CSS file on + /// disk, or persisting `settings.json`. + Io(io::Error), +} + +impl fmt::Display for CssError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CssError::NotFound => write!(f, "no CSS entry with that id"), + CssError::NotImported => write!(f, "CSS entry is linked, not imported"), + CssError::Io(err) => write!(f, "I/O error: {err}"), + } + } +} + +impl std::error::Error for CssError {} + +impl From for CssError { + fn from(err: io::Error) -> Self { + CssError::Io(err) + } +} + +/// Computes the id used to reference a `CssEntry` over the API. +/// +/// For `Imported` entries this is just the stored id. `Linked` entries have +/// no stored id (ottyl doesn't own them), so one is derived deterministically +/// by hashing the `path_or_url`; this lets the frontend key/reference linked +/// entries the same way as imported ones (e.g. for list rendering) without +/// ottyl needing to persist an id it has no other use for. Because it's +/// derived from content, the same `path_or_url` always maps to the same id. +pub fn css_entry_id(entry: &CssEntry) -> String { + match entry { + CssEntry::Imported { id, .. } => id.clone(), + CssEntry::Linked { path_or_url } => linked_entry_id(path_or_url), + } +} + +fn linked_entry_id(path_or_url: &str) -> String { + let mut hasher = DefaultHasher::new(); + path_or_url.hash(&mut hasher); + format!("linked-{:016x}", hasher.finish()) +} + +/// Generates an id for a newly-imported CSS entry. Not cryptographically +/// meaningful — just needs to be unique enough in practice: it hashes the +/// filename, content, and current time together, avoiding a new dependency +/// (e.g. `uuid`) for what's otherwise a random-ish opaque string. +fn generate_imported_css_id(filename: &str, content: &str) -> String { + let mut hasher = DefaultHasher::new(); + filename.hash(&mut hasher); + content.hash(&mut hasher); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + nanos.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf { + css_dir.join(format!("{id}.css")) +} + +impl Settings { + /// Imports raw CSS `content`: writes it to `/.css` (id + /// generated from the filename/content/timestamp, see + /// `generate_imported_css_id`), records a `CssEntry::Imported { id, + /// filename }` in `css_entries`, and persists settings to + /// `settings_path`. Returns the generated id. + /// + /// `css_dir` is expected to be the settings file's parent directory + /// (i.e. CSS files live alongside `settings.json`). + pub fn import_css( + &mut self, + settings_path: &Path, + css_dir: &Path, + filename: String, + content: &str, + ) -> Result { + fs::create_dir_all(css_dir)?; + let id = generate_imported_css_id(&filename, content); + fs::write(imported_css_file_path(css_dir, &id), content)?; + self.css_entries.push(CssEntry::Imported { + id: id.clone(), + filename, + }); + self.save(settings_path)?; + Ok(id) + } + + /// Registers a reference to an external CSS path/URL without copying + /// its content. Persists settings to `settings_path`. + pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> { + self.css_entries.push(CssEntry::Linked { path_or_url }); + self.save(settings_path)?; + Ok(()) + } + + /// Removes an imported CSS entry identified by `id` (see + /// `css_entry_id`): deletes its file under `css_dir` and drops it from + /// `css_entries`, then persists settings to `settings_path`. + /// + /// Returns `CssError::NotFound` if no entry matches `id`, and + /// `CssError::NotImported` if `id` refers to a `Linked` entry — linked + /// entries are never deletable this way since ottyl doesn't own their + /// content. + pub fn remove_css( + &mut self, + settings_path: &Path, + css_dir: &Path, + id: &str, + ) -> Result<(), CssError> { + let index = self + .css_entries + .iter() + .position(|entry| css_entry_id(entry) == id); + match index.map(|i| &self.css_entries[i]) { + None => Err(CssError::NotFound), + Some(CssEntry::Linked { .. }) => Err(CssError::NotImported), + Some(CssEntry::Imported { .. }) => { + let idx = index.unwrap(); + self.css_entries.remove(idx); + // Best-effort: if the file is already gone, the settings + // reference is still what matters and has been removed above. + let _ = fs::remove_file(imported_css_file_path(css_dir, id)); + self.save(settings_path)?; + Ok(()) + } + } + } + + /// Reads back the raw content of an imported CSS entry identified by + /// `id` (see `css_entry_id`). + /// + /// Returns `CssError::NotFound` if no entry matches `id`, and + /// `CssError::NotImported` if `id` refers to a `Linked` entry (there's no + /// local content to serve for those — the frontend should link/fetch the + /// original `path_or_url` directly instead). + pub fn css_content(&self, css_dir: &Path, id: &str) -> Result { + match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) { + None => Err(CssError::NotFound), + Some(CssEntry::Linked { .. }) => Err(CssError::NotImported), + Some(CssEntry::Imported { .. }) => { + Ok(fs::read_to_string(imported_css_file_path(css_dir, id))?) + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -196,4 +360,105 @@ mod tests { assert!(path.exists()); } + + #[test] + fn import_css_persists_file_and_entry_across_reload() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + let css_dir = dir.path().to_path_buf(); + + let mut settings = Settings::load(&settings_path); + let id = settings + .import_css( + &settings_path, + &css_dir, + "theme.css".to_string(), + "body { color: red; }", + ) + .unwrap(); + + let css_file = css_dir.join(format!("{id}.css")); + assert!(css_file.exists()); + assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }"); + + // Drop and reload settings from disk (simulates a restart). + drop(settings); + let reloaded = Settings::load(&settings_path); + assert!(reloaded.css_entries.iter().any(|entry| matches!( + entry, + CssEntry::Imported { id: entry_id, filename } if entry_id == &id && filename == "theme.css" + ))); + assert!(css_file.exists()); + assert_eq!( + reloaded.css_content(&css_dir, &id).unwrap(), + "body { color: red; }" + ); + } + + #[test] + fn remove_css_deletes_file_and_entry() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + let css_dir = dir.path().to_path_buf(); + + let mut settings = Settings::load(&settings_path); + let id = settings + .import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}") + .unwrap(); + let css_file = css_dir.join(format!("{id}.css")); + assert!(css_file.exists()); + + settings.remove_css(&settings_path, &css_dir, &id).unwrap(); + + assert!(!css_file.exists()); + assert!(!settings.css_entries.iter().any(|e| css_entry_id(e) == id)); + + let reloaded = Settings::load(&settings_path); + assert!(!reloaded.css_entries.iter().any(|e| css_entry_id(e) == id)); + } + + #[test] + fn remove_css_rejects_linked_entry() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + let css_dir = dir.path().to_path_buf(); + + let mut settings = Settings::load(&settings_path); + settings + .link_css(&settings_path, "https://example.com/style.css".to_string()) + .unwrap(); + let linked_id = css_entry_id(&settings.css_entries[0]); + + let result = settings.remove_css(&settings_path, &css_dir, &linked_id); + assert!(matches!(result, Err(CssError::NotImported))); + // Entry must still be there — rejection shouldn't silently drop it. + assert_eq!(settings.css_entries.len(), 1); + } + + #[test] + fn remove_css_rejects_unknown_id() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + let css_dir = dir.path().to_path_buf(); + + let mut settings = Settings::load(&settings_path); + let result = settings.remove_css(&settings_path, &css_dir, "does-not-exist"); + assert!(matches!(result, Err(CssError::NotFound))); + } + + #[test] + fn css_content_rejects_linked_entry() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + let css_dir = dir.path().to_path_buf(); + + let mut settings = Settings::load(&settings_path); + settings + .link_css(&settings_path, "https://example.com/style.css".to_string()) + .unwrap(); + let linked_id = css_entry_id(&settings.css_entries[0]); + + let result = settings.css_content(&css_dir, &linked_id); + assert!(matches!(result, Err(CssError::NotImported))); + } }