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(); }