Files
helix/src/lib/server/gitea.ts
Rene De Ren c3d978a7eb feat: initial HELIX scaffold — R&D showcase platform
SvelteKit 2 + Svelte 5 + TypeScript site. SQLite via Drizzle. Gitea OAuth
for authoring (RnD org-gated). Pure SVG + CSS DNA helix on landing.

What lands
- Landing hero with animated two-strand SVG helix + tagline
- /projects + /projects/[slug] (markdown body, dashboard embed allowlist)
- /posts + /posts/[slug]
- Auth-gated /projects/new + /posts/new forms
- Gitea OAuth flow (state, code exchange, org-membership check, sessions)
- Sliding-window cookie sessions (SHA-256 hashed token storage)
- Dockerfile + docker-compose with named-volume SQLite
- Idempotent seed (EVOLV + HELIX projects, welcome post)

Stack notes
- Tailwind v3 (Node 18 compat; v4 needs Node 20+)
- drizzle-orm 0.45+ (patched, no SQL-identifier escape vuln)
- marked for markdown; iframe embeds gated by DASHBOARD_ALLOWED_HOSTS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:01:12 +02:00

88 lines
2.9 KiB
TypeScript

import { env } from '$env/dynamic/private';
import { encodeBase32LowerCaseNoPadding } from '@oslojs/encoding';
const baseURL = env.GITEA_BASE_URL ?? 'https://gitea.wbd-rd.nl';
const clientId = env.GITEA_CLIENT_ID ?? '';
const clientSecret = env.GITEA_CLIENT_SECRET ?? '';
const redirectURI = env.GITEA_REDIRECT_URI ?? 'http://localhost:3000/auth/gitea/callback';
const allowedOrg = env.GITEA_ALLOWED_ORG ?? '';
export const GITEA_BASE_URL = baseURL;
export const GITEA_ALLOWED_ORG = allowedOrg;
export interface GiteaUser {
id: number;
login: string;
full_name?: string;
email?: string;
avatar_url?: string;
}
interface GiteaOrg {
username?: string;
name?: string;
}
export function generateState(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
export function buildAuthorizationURL(state: string): string {
if (!clientId) throw new Error('GITEA_CLIENT_ID is not configured');
const u = new URL(`${baseURL}/login/oauth/authorize`);
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectURI);
u.searchParams.set('response_type', 'code');
u.searchParams.set('state', state);
// Gitea recognises space-separated scopes. read:user + read:organization
// are sufficient for identity and org-membership checks.
u.searchParams.set('scope', 'read:user read:organization');
return u.toString();
}
export async function exchangeCode(code: string): Promise<string> {
if (!clientSecret) throw new Error('GITEA_CLIENT_SECRET is not configured');
const res = await fetch(`${baseURL}/login/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectURI
})
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Gitea token exchange failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { access_token?: string };
if (!data.access_token) throw new Error('Gitea response missing access_token');
return data.access_token;
}
export async function fetchGiteaUser(accessToken: string): Promise<GiteaUser> {
const res = await fetch(`${baseURL}/api/v1/user`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) throw new Error(`Failed to fetch Gitea user: ${res.status}`);
return (await res.json()) as GiteaUser;
}
export async function isUserInOrg(
accessToken: string,
login: string,
org: string
): Promise<boolean> {
if (!org) return true;
const res = await fetch(`${baseURL}/api/v1/users/${encodeURIComponent(login)}/orgs`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) return false;
const orgs = (await res.json()) as GiteaOrg[];
return orgs.some((o) => o.username === org || o.name === org);
}