feat: operational tooling — signing, versioning, changelog, roadmap (SPEC-001)
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>
This commit is contained in:
@@ -22,6 +22,12 @@ LISTEN_ADDR=0.0.0.0:3002
|
||||
# If set, persistent agents must provide this key to connect
|
||||
AGENT_API_KEY=
|
||||
|
||||
# Optional: directory containing generated changelog files served at /api/changelog/...
|
||||
# Must point at the deployed `changelogs/` directory produced by the release workflow
|
||||
# (containing `LATEST_<COMPONENT>.md` and `<component>/v<version>.md`).
|
||||
# Defaults to ./changelogs, resolved relative to the server's working directory (CWD) when unset.
|
||||
CHANGELOG_DIR=./changelogs
|
||||
|
||||
# Debug mode (enables verbose logging)
|
||||
DEBUG=false
|
||||
|
||||
|
||||
125
server/src/api/changelog.rs
Normal file
125
server/src/api/changelog.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod auth_logout;
|
||||
pub mod changelog;
|
||||
pub mod users;
|
||||
pub mod releases;
|
||||
pub mod downloads;
|
||||
|
||||
@@ -304,6 +304,11 @@ async fn main() -> Result<()> {
|
||||
.route("/api/releases/:version", put(api::releases::update_release))
|
||||
.route("/api/releases/:version", delete(api::releases::delete_release))
|
||||
|
||||
// Changelog (no auth - public, like /api/version)
|
||||
// Single route: version == "latest" selects the latest file; axum 0.7 / matchit 0.7
|
||||
// panics if a static segment and a path param share this position, so do not split it.
|
||||
.route("/api/changelog/:component/:version", get(api::changelog::get))
|
||||
|
||||
// Agent downloads (no auth - public download links)
|
||||
.route("/api/download/viewer", get(api::downloads::download_viewer))
|
||||
.route("/api/download/support", get(api::downloads::download_support))
|
||||
|
||||
Reference in New Issue
Block a user