From 6b9ce99a060cfd5423ce9b5a1fd245fe67c3ab98 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 5 May 2026 16:24:32 +0300 Subject: [PATCH] fix: proxy forge API requests to avoid CORS, case-insensitive readme Add /v1/forge/{source}/* proxy endpoint to the API server with an allowlisted set of hosts. Frontend readme and language requests now go through the proxy instead of hitting forge APIs directly (Gitea has no CORS headers). Gitea readme fetch tries README.md, readme.md, and Readme.md casings. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/moments-api/Cargo.toml | 1 + crates/moments-api/src/main.rs | 66 ++++++++++++++++++++++++++++++++-- ui/src/api/client.ts | 28 +++++++-------- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/moments-api/Cargo.toml b/crates/moments-api/Cargo.toml index 0834301..58efa8e 100644 --- a/crates/moments-api/Cargo.toml +++ b/crates/moments-api/Cargo.toml @@ -20,3 +20,4 @@ serde.workspace = true serde_json.workspace = true chrono.workspace = true clap.workspace = true +reqwest.workspace = true diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index 0bbf7cd..f20f091 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -1,8 +1,8 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::{net::SocketAddr, sync::Arc, time::Duration}; use axum::{ Json, Router, - extract::{Query, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::get, @@ -29,6 +29,7 @@ struct Args { #[derive(Clone)] struct AppState { store: Arc, + http: reqwest::Client, } #[tokio::main] @@ -42,8 +43,12 @@ async fn main() -> anyhow::Result<()> { // public`. The worker must have run at least once before the api accepts // traffic; in deploy this is ordered via systemd dependencies (ยง3). let store = PgStore::connect(&args.database_url).await?; + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build()?; let state = AppState { store: Arc::new(store), + http, }; let app = Router::new() @@ -51,6 +56,7 @@ async fn main() -> anyhow::Result<()> { .route("/v1/events", get(list_events)) .route("/v1/sources", get(list_sources)) .route("/v1/projects", get(list_projects)) + .route("/v1/forge/{source}/{*rest}", get(forge_proxy)) .with_state(state) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()); @@ -133,6 +139,62 @@ async fn list_projects( Ok(Json(projects)) } +/// Allowlisted forge hosts that the proxy may contact. +const ALLOWED_HOSTS: &[&str] = &["api.github.com", "git.lair.cafe"]; + +#[derive(Debug, Deserialize)] +struct ForgeProxyParams { + host: Option, +} + +/// Proxy requests to forge APIs to avoid CORS issues. +/// `GET /v1/forge/{source}/{path}?host=git.lair.cafe` +async fn forge_proxy( + State(state): State, + Path((source, rest)): Path<(String, String)>, + Query(params): Query, +) -> Result { + let (base, api_prefix) = match source.as_str() { + "github" => ("https://api.github.com".to_string(), ""), + "gitea" => { + let host = params.host.as_deref().unwrap_or("git.lair.cafe"); + if !ALLOWED_HOSTS.contains(&host) { + return Err(ApiError::bad_request(format!("host not allowed: {host}"))); + } + (format!("https://{host}"), "/api/v1") + } + _ => return Err(ApiError::bad_request(format!("unsupported source: {source}"))), + }; + + let url = format!("{base}{api_prefix}/{rest}"); + let resp = state + .http + .get(&url) + .header("Accept", "application/json") + .header("User-Agent", "moments-api") + .send() + .await + .map_err(|e| { + tracing::warn!(url = %url, error = %e, "forge proxy request failed"); + ApiError { + status: StatusCode::BAD_GATEWAY, + message: e.to_string(), + } + })?; + + let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let body = resp.bytes().await.map_err(|e| ApiError { + status: StatusCode::BAD_GATEWAY, + message: e.to_string(), + })?; + + Ok(( + status, + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + )) +} + fn parse_sources(raw: &str) -> Result, ApiError> { raw.split(',') .map(str::trim) diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 1a34212..4e7fd76 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -102,19 +102,20 @@ export async function fetchProjects(): Promise { return resp.json(); } -/** Fetch repo README as raw markdown. */ +/** Fetch repo README as raw markdown via the forge proxy. */ export async function fetchReadme(source: Source, host: string, repo: string): Promise { if (source === 'github') { - const resp = await fetch(`https://api.github.com/repos/${repo}/readme`, { - headers: { 'Accept': 'application/vnd.github.raw+json' }, - }); + const resp = await fetch(`${API_BASE}/forge/github/repos/${repo}/readme`); if (!resp.ok) return null; - return resp.text(); + const data = await resp.json(); + if (data.encoding === 'base64' && data.content) { + return atob(data.content); + } + return data.content ?? null; } if (source === 'gitea') { - // Gitea returns JSON with base64-encoded content. Try common casings. for (const name of ['README.md', 'readme.md', 'Readme.md']) { - const resp = await fetch(`https://${host}/api/v1/repos/${repo}/contents/${name}`); + const resp = await fetch(`${API_BASE}/forge/gitea/repos/${repo}/contents/${name}?host=${encodeURIComponent(host)}`); if (!resp.ok) continue; const data = await resp.json(); if (data.encoding === 'base64' && data.content) { @@ -127,16 +128,11 @@ export async function fetchReadme(source: Source, host: string, repo: string): P return null; } -/** Fetch repo languages as { language: bytes } map. */ +/** Fetch repo languages as { language: bytes } map via the forge proxy. */ export async function fetchLanguages(source: Source, host: string, repo: string): Promise | null> { - const baseUrl = source === 'github' - ? `https://api.github.com/repos/${repo}/languages` - : source === 'gitea' - ? `https://${host}/api/v1/repos/${repo}/languages` - : null; - if (!baseUrl) return null; - - const resp = await fetch(baseUrl); + if (source !== 'github' && source !== 'gitea') return null; + const hostParam = source === 'gitea' ? `?host=${encodeURIComponent(host)}` : ''; + const resp = await fetch(`${API_BASE}/forge/${source}/repos/${repo}/languages${hostParam}`); if (!resp.ok) return null; return resp.json(); }