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-data",
"moments-entities",
"reqwest",
"serde",
"serde_json",
"tokio",

View File

@@ -20,3 +20,4 @@ serde.workspace = true
serde_json.workspace = true
chrono.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::{
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)

View File

@@ -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(
AND ($6::text IS NULL OR (CASE source
WHEN 'github' THEN COALESCE(
payload->'repo'->>'name',
payload->'repository'->>'full_name',
payload->>'_repo',
payload->>'product'
) = $6)
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(
CASE source
WHEN 'github' THEN COALESCE(
payload->'repo'->>'name',
payload->'repository'->>'full_name',
payload->>'_repo',
payload->>'product'
) AS repo,
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')

View File

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

View File

@@ -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 (source === 'github') {
const resp = await fetch(`${API_BASE}/forge/github/repos/${repo}/readme`);
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> {
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();
}