//! Changelog endpoints — serve per-component release notes from the changelog directory. //! //! `GET /api/changelog/:component/:version` serves changelog Markdown for a component: //! - `version == "latest"` → most recent changelog (`LATEST_AGENT.md`, etc.) //! - `version == "0.1.0"` (or `v0.1.0`) → a specific version (`agent/v0.1.0.md`) //! //! A single route is used because axum 0.7 / matchit 0.7 panics at router construction when a //! static segment (`latest`) and a path param (`:version`) occupy the same position; the handler //! dispatches on the literal value `latest` instead. //! //! This endpoint is public (no auth), mirroring `GET /api/version`. The base directory is read //! from the `CHANGELOG_DIR` env var (default `./changelogs`). Both the `component` and `version` //! path parameters are validated/sanitized to prevent path traversal. use axum::{ extract::Path, http::{header, StatusCode}, response::{IntoResponse, Response}, Json, }; use std::path::PathBuf; use super::auth::ErrorResponse; /// Resolve the base changelog directory from the `CHANGELOG_DIR` env var. /// /// Defaults to `./changelogs` (relative to the server's working directory) when unset. fn changelog_dir() -> PathBuf { std::env::var("CHANGELOG_DIR") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("./changelogs")) } /// Whether `component` is a recognized GuruConnect component. /// /// Restricting to a fixed allow-list is the primary defense against path traversal: an /// attacker cannot smuggle `..` or absolute paths through this parameter. fn valid_component(component: &str) -> bool { matches!(component, "agent" | "server" | "dashboard") } /// Build a `404 Not Found` response using the project's standard error envelope. fn not_found(message: &str) -> Response { ( StatusCode::NOT_FOUND, Json(ErrorResponse { error: message.to_string(), }), ) .into_response() } /// Build a `200 OK` Markdown response. fn markdown_response(content: String) -> Response { ( StatusCode::OK, [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], content, ) .into_response() } /// `GET /api/changelog/:component/:version` /// /// Serves changelog Markdown for `component`. The `version` segment is dispatched as follows: /// - `latest` → `changelogs/LATEST_.md` (uppercase component) /// - a semver string (with or without a leading `v`, e.g. `0.1.0` or `v0.1.0`) /// → `changelogs//v.md` /// /// `component` is validated against the allow-list FIRST; the version (when not `latest`) is /// sanitized to digits and dots only, rejecting any path-traversal attempt before touching the /// filesystem. pub async fn get(Path((component, version)): Path<(String, String)>) -> Response { // Allow-list check first: the primary defense against path traversal via `component`. if !valid_component(&component) { return not_found("Unknown component"); } // The literal "latest" selects the most-recent changelog file for the component. if version == "latest" { let file = changelog_dir().join(format!("LATEST_{}.md", component.to_uppercase())); return match tokio::fs::read_to_string(&file).await { Ok(content) => markdown_response(content), Err(e) => { tracing::warn!( "Changelog latest not found for component '{}' ({}): {}", component, file.display(), e ); not_found("Changelog not found") } }; } // Otherwise treat `version` as a specific release. Accept it with or without a leading 'v'; // work with the bare numeric form. let bare = version.strip_prefix('v').unwrap_or(&version); // Sanitize: a valid version is non-empty and contains only digits and dots. This rejects any // path-traversal attempt (slashes, `..`, backslashes) before touching the filesystem. let safe = !bare.is_empty() && bare.chars().all(|c| c.is_ascii_digit() || c == '.') && !bare.contains(".."); if !safe { return not_found("Invalid version"); } let file = changelog_dir().join(&component).join(format!("v{bare}.md")); match tokio::fs::read_to_string(&file).await { Ok(content) => markdown_response(content), Err(e) => { tracing::warn!( "Changelog not found for component '{}' version 'v{}' ({}): {}", component, bare, file.display(), e ); not_found("Changelog not found") } } }