Compare commits

...

6 Commits

Author SHA1 Message Date
7a4939cc41 chore(ui): add favicon set to index.html
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 16:48:52 +03:00
0d350ce584 fix: decode base64 readme content as utf-8 instead of latin-1
atob() produces Latin-1 strings, mangling multi-byte UTF-8 characters
like box-drawing glyphs. Use TextDecoder for correct UTF-8 handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 16:28:40 +03:00
1275a7785f chore: update Cargo.lock for reqwest in moments-api
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 16:25:19 +03:00
6b9ce99a06 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>
2026-05-05 16:24:32 +03:00
f676ecdc19 fix: try multiple readme filename casings for Gitea repos
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 16:19:34 +03:00
46ef63a68e fix: source-aware repo extraction, Gitea readme/languages endpoints
Use CASE/source instead of COALESCE for repo name extraction — Gitea's
repo.name is the short name while full_name includes the owner prefix.
Fix Gitea README fetch to use /contents/README.md with base64 decoding
instead of the nonexistent /readme endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 16:18:40 +03:00
6 changed files with 131 additions and 36 deletions

1
Cargo.lock generated
View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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')

View File

@@ -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>

View File

@@ -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();
} }