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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user