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:
@@ -20,3 +20,4 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::get,
|
||||||
@@ -29,6 +29,7 @@ struct Args {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
store: Arc<PgStore>,
|
store: Arc<PgStore>,
|
||||||
|
http: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -42,8 +43,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// public`. The worker must have run at least once before the api accepts
|
// public`. The worker must have run at least once before the api accepts
|
||||||
// traffic; in deploy this is ordered via systemd dependencies (§3).
|
// traffic; in deploy this is ordered via systemd dependencies (§3).
|
||||||
let store = PgStore::connect(&args.database_url).await?;
|
let store = PgStore::connect(&args.database_url).await?;
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(15))
|
||||||
|
.build()?;
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
store: Arc::new(store),
|
store: Arc::new(store),
|
||||||
|
http,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -51,6 +56,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/v1/events", get(list_events))
|
.route("/v1/events", get(list_events))
|
||||||
.route("/v1/sources", get(list_sources))
|
.route("/v1/sources", get(list_sources))
|
||||||
.route("/v1/projects", get(list_projects))
|
.route("/v1/projects", get(list_projects))
|
||||||
|
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(CorsLayer::permissive());
|
.layer(CorsLayer::permissive());
|
||||||
@@ -133,6 +139,62 @@ async fn list_projects(
|
|||||||
Ok(Json(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> {
|
fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
|
||||||
raw.split(',')
|
raw.split(',')
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|||||||
@@ -102,19 +102,20 @@ export async function fetchProjects(): Promise<ProjectSummary[]> {
|
|||||||
return resp.json();
|
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> {
|
export async function fetchReadme(source: Source, host: string, repo: string): Promise<string | null> {
|
||||||
if (source === 'github') {
|
if (source === 'github') {
|
||||||
const resp = await fetch(`https://api.github.com/repos/${repo}/readme`, {
|
const resp = await fetch(`${API_BASE}/forge/github/repos/${repo}/readme`);
|
||||||
headers: { 'Accept': 'application/vnd.github.raw+json' },
|
|
||||||
});
|
|
||||||
if (!resp.ok) return null;
|
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') {
|
if (source === 'gitea') {
|
||||||
// Gitea returns JSON with base64-encoded content. Try common casings.
|
|
||||||
for (const name of ['README.md', 'readme.md', 'Readme.md']) {
|
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;
|
if (!resp.ok) continue;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.encoding === 'base64' && data.content) {
|
if (data.encoding === 'base64' && data.content) {
|
||||||
@@ -127,16 +128,11 @@ export async function fetchReadme(source: Source, host: string, repo: string): P
|
|||||||
return null;
|
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> {
|
export async function fetchLanguages(source: Source, host: string, repo: string): Promise<Record<string, number> | null> {
|
||||||
const baseUrl = source === 'github'
|
if (source !== 'github' && source !== 'gitea') return null;
|
||||||
? `https://api.github.com/repos/${repo}/languages`
|
const hostParam = source === 'gitea' ? `?host=${encodeURIComponent(host)}` : '';
|
||||||
: source === 'gitea'
|
const resp = await fetch(`${API_BASE}/forge/${source}/repos/${repo}/languages${hostParam}`);
|
||||||
? `https://${host}/api/v1/repos/${repo}/languages`
|
|
||||||
: null;
|
|
||||||
if (!baseUrl) return null;
|
|
||||||
|
|
||||||
const resp = await fetch(baseUrl);
|
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) return null;
|
||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user