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>
88 lines
2.9 KiB
TypeScript
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);
|
|
}
|