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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
203 lines
7.6 KiB
Rust
203 lines
7.6 KiB
Rust
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<Mutex<Settings>>;
|
|
|
|
#[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<PathBuf>,
|
|
}
|
|
|
|
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<EngineResponse, StatusCode> {
|
|
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<ApiState>,
|
|
Query(params): Query<LogsParams>,
|
|
) -> 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<ApiState>,
|
|
Query(params): Query<TimelineParams>,
|
|
) -> 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<ApiState>) -> 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<CssEntryDto>`.
|
|
async fn list_css(State(state): State<ApiState>) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
let entries: Vec<CssEntryDto> = 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<ApiState>,
|
|
Json(body): Json<ImportCssRequest>,
|
|
) -> 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<ApiState>,
|
|
Json(body): Json<LinkCssRequest>,
|
|
) -> 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<ApiState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
) -> 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<ApiState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
) -> 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),
|
|
}
|
|
}
|