Compare commits
6 Commits
ba216580ea
...
7a4939cc41
| Author | SHA1 | Date | |
|---|---|---|---|
|
7a4939cc41
|
|||
|
0d350ce584
|
|||
|
1275a7785f
|
|||
|
6b9ce99a06
|
|||
|
f676ecdc19
|
|||
|
46ef63a68e
|
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1130,6 +1130,7 @@ dependencies = [
|
||||
"moments-core",
|
||||
"moments-data",
|
||||
"moments-entities",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
||||
@@ -20,3 +20,4 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.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::{
|
||||
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)
|
||||
|
||||
@@ -54,12 +54,19 @@ impl EventReader for PgStore {
|
||||
AND ($2::timestamptz IS NULL OR occurred_at < $2)
|
||||
AND ($3::text[] IS NULL OR source = ANY($3))
|
||||
AND ($4::bool OR public = true)
|
||||
AND ($6::text IS NULL OR COALESCE(
|
||||
payload->'repo'->>'name',
|
||||
payload->'repository'->>'full_name',
|
||||
payload->>'_repo',
|
||||
payload->>'product'
|
||||
) = $6)
|
||||
AND ($6::text IS NULL OR (CASE source
|
||||
WHEN 'github' THEN COALESCE(
|
||||
payload->'repo'->>'name',
|
||||
payload->'repository'->>'full_name'
|
||||
)
|
||||
WHEN 'gitea' THEN COALESCE(
|
||||
payload->'repo'->>'full_name',
|
||||
payload->'repo'->>'name'
|
||||
)
|
||||
WHEN 'hg' THEN payload->>'_repo'
|
||||
WHEN 'bugzilla' THEN payload->>'product'
|
||||
ELSE NULL
|
||||
END) = $6)
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT $5
|
||||
"#,
|
||||
@@ -134,12 +141,19 @@ impl EventReader for PgStore {
|
||||
MAX(occurred_at) AS last_activity
|
||||
FROM (
|
||||
SELECT source, occurred_at,
|
||||
COALESCE(
|
||||
payload->'repo'->>'name',
|
||||
payload->'repository'->>'full_name',
|
||||
payload->>'_repo',
|
||||
payload->>'product'
|
||||
) AS repo,
|
||||
CASE source
|
||||
WHEN 'github' THEN COALESCE(
|
||||
payload->'repo'->>'name',
|
||||
payload->'repository'->>'full_name'
|
||||
)
|
||||
WHEN 'gitea' THEN COALESCE(
|
||||
payload->'repo'->>'full_name',
|
||||
payload->'repo'->>'name'
|
||||
)
|
||||
WHEN 'hg' THEN payload->>'_repo'
|
||||
WHEN 'bugzilla' THEN payload->>'product'
|
||||
ELSE NULL
|
||||
END AS repo,
|
||||
CASE source
|
||||
WHEN 'github' THEN 'github.com'
|
||||
WHEN 'gitea' THEN COALESCE(payload->>'_host', 'git.lair.cafe')
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>rob.tn</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -75,6 +75,12 @@ export interface EventQuery {
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
/** Decode base64 content as UTF-8 (atob only handles Latin-1). */
|
||||
function decodeBase64Utf8(b64: string): string {
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export async function fetchEvents(q: EventQuery): Promise<TimelineItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.from) params.set('from', q.from.toISOString());
|
||||
@@ -102,32 +108,37 @@ export async function fetchProjects(): Promise<ProjectSummary[]> {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** Fetch repo README as rendered HTML or 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> {
|
||||
const baseUrl = source === 'github'
|
||||
? `https://api.github.com/repos/${repo}/readme`
|
||||
: source === 'gitea'
|
||||
? `https://${host}/api/v1/repos/${repo}/readme`
|
||||
: null;
|
||||
if (!baseUrl) return null;
|
||||
|
||||
const resp = await fetch(baseUrl, {
|
||||
headers: { 'Accept': 'application/vnd.github.raw+json' },
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return resp.text();
|
||||
if (source === 'github') {
|
||||
const resp = await fetch(`${API_BASE}/forge/github/repos/${repo}/readme`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (data.encoding === 'base64' && data.content) {
|
||||
return decodeBase64Utf8(data.content);
|
||||
}
|
||||
return data.content ?? null;
|
||||
}
|
||||
if (source === 'gitea') {
|
||||
for (const name of ['README.md', 'readme.md', 'Readme.md']) {
|
||||
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) {
|
||||
return decodeBase64Utf8(data.content);
|
||||
}
|
||||
if (data.content) return data.content;
|
||||
}
|
||||
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> {
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user