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) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 16:24:32 +03:00
parent f676ecdc19
commit 6b9ce99a06
3 changed files with 77 additions and 18 deletions

View File

@@ -20,3 +20,4 @@ serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true

View File

@@ -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<PgStore>,
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<String>,
}
/// 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<AppState>,
Path((source, rest)): Path<(String, String)>,
Query(params): Query<ForgeProxyParams>,
) -> Result<impl IntoResponse, ApiError> {
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<Vec<Source>, ApiError> {
raw.split(',')
.map(str::trim)

View File

@@ -102,19 +102,20 @@ export async function fetchProjects(): Promise<ProjectSummary[]> {
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<string | null> {
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<Record<string, number> | 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();
}