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-core",
|
||||||
"moments-data",
|
"moments-data",
|
||||||
"moments-entities",
|
"moments-entities",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -54,12 +54,19 @@ impl EventReader for PgStore {
|
|||||||
AND ($2::timestamptz IS NULL OR occurred_at < $2)
|
AND ($2::timestamptz IS NULL OR occurred_at < $2)
|
||||||
AND ($3::text[] IS NULL OR source = ANY($3))
|
AND ($3::text[] IS NULL OR source = ANY($3))
|
||||||
AND ($4::bool OR public = true)
|
AND ($4::bool OR public = true)
|
||||||
AND ($6::text IS NULL OR COALESCE(
|
AND ($6::text IS NULL OR (CASE source
|
||||||
|
WHEN 'github' THEN COALESCE(
|
||||||
payload->'repo'->>'name',
|
payload->'repo'->>'name',
|
||||||
payload->'repository'->>'full_name',
|
payload->'repository'->>'full_name'
|
||||||
payload->>'_repo',
|
)
|
||||||
payload->>'product'
|
WHEN 'gitea' THEN COALESCE(
|
||||||
) = $6)
|
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
|
ORDER BY occurred_at DESC
|
||||||
LIMIT $5
|
LIMIT $5
|
||||||
"#,
|
"#,
|
||||||
@@ -134,12 +141,19 @@ impl EventReader for PgStore {
|
|||||||
MAX(occurred_at) AS last_activity
|
MAX(occurred_at) AS last_activity
|
||||||
FROM (
|
FROM (
|
||||||
SELECT source, occurred_at,
|
SELECT source, occurred_at,
|
||||||
COALESCE(
|
CASE source
|
||||||
|
WHEN 'github' THEN COALESCE(
|
||||||
payload->'repo'->>'name',
|
payload->'repo'->>'name',
|
||||||
payload->'repository'->>'full_name',
|
payload->'repository'->>'full_name'
|
||||||
payload->>'_repo',
|
)
|
||||||
payload->>'product'
|
WHEN 'gitea' THEN COALESCE(
|
||||||
) AS repo,
|
payload->'repo'->>'full_name',
|
||||||
|
payload->'repo'->>'name'
|
||||||
|
)
|
||||||
|
WHEN 'hg' THEN payload->>'_repo'
|
||||||
|
WHEN 'bugzilla' THEN payload->>'product'
|
||||||
|
ELSE NULL
|
||||||
|
END AS repo,
|
||||||
CASE source
|
CASE source
|
||||||
WHEN 'github' THEN 'github.com'
|
WHEN 'github' THEN 'github.com'
|
||||||
WHEN 'gitea' THEN COALESCE(payload->>'_host', 'git.lair.cafe')
|
WHEN 'gitea' THEN COALESCE(payload->>'_host', 'git.lair.cafe')
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>rob.tn</title>
|
<title>rob.tn</title>
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export interface EventQuery {
|
|||||||
|
|
||||||
const API_BASE = '/api/v1';
|
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[]> {
|
export async function fetchEvents(q: EventQuery): Promise<TimelineItem[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (q.from) params.set('from', q.from.toISOString());
|
if (q.from) params.set('from', q.from.toISOString());
|
||||||
@@ -102,32 +108,37 @@ export async function fetchProjects(): Promise<ProjectSummary[]> {
|
|||||||
return resp.json();
|
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> {
|
export async function fetchReadme(source: Source, host: string, repo: string): Promise<string | null> {
|
||||||
const baseUrl = source === 'github'
|
if (source === 'github') {
|
||||||
? `https://api.github.com/repos/${repo}/readme`
|
const resp = await fetch(`${API_BASE}/forge/github/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;
|
if (!resp.ok) return null;
|
||||||
return resp.text();
|
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> {
|
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