Establish GuruConnect's release engineering and project tracking (SPEC-001): - docs/ scaffold: FEATURE_ROADMAP, ARCHITECTURE_DECISIONS (ADR-001 standalone+contract, ADR-002 Gitea Actions + Azure Trusted Signing), docs/specs/SPEC-001, CHANGELOG. - .gitea/workflows/release.yml: conventional-commit auto-versioning, git-cliff changelog, Windows agent build, Azure Trusted Signing via jsign (reusing the shared ACG cert profile), Gitea release via REST API. build-and-test.yml is the PR/push gate; deploy.yml de-duplicated. - server: GET /api/changelog/:component/:version (latest + by-version), path-traversal hardened. - cliff.toml; server/.env.example documents CHANGELOG_DIR. Reviewed (Code Review Agent): axum route-conflict blocker fixed; CHANGELOG ordering, toolchain target, breaking-change parsing, empty-changelog fallback addressed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
4.7 KiB
Rust
126 lines
4.7 KiB
Rust
//! 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_<COMPONENT>.md` (uppercase component)
|
|
/// - a semver string (with or without a leading `v`, e.g. `0.1.0` or `v0.1.0`)
|
|
/// → `changelogs/<component>/v<version>.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")
|
|
}
|
|
}
|
|
}
|