import type { GitHubAccount, GitHubKey, GitHubRepo } from "$lib/github"; import { type GitHubEnv, hostOf } from "./env"; export class GitHubUpstreamError extends Error { constructor(public status: number) { super("github upstream"); } } const malformed = () => new Error("github malformed response"); type Json = Record; const object = (value: unknown): Json | null => value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Json) : null; const text = (o: Json, key: string) => (typeof o[key] === "string" ? o[key] : null); const integer = (o: Json, key: string) => typeof o[key] === "number" && Number.isInteger(o[key]) ? o[key] : null; interface GitHubCaller { env: GitHubEnv; token: string; fetch: typeof fetch; } const send = async (call: GitHubCaller, url: string) => { const response = await call.fetch(url, { redirect: "error", headers: { accept: "application/vnd.github+json", authorization: `Bearer ${call.token}`, "user-agent": "tangled" } }); if (!response.ok) throw new GitHubUpstreamError(response.status); return { body: (await response.json()) as unknown, link: response.headers.get("link") }; }; const one = async (call: GitHubCaller, path: string): Promise => { const { body } = await send(call, `${call.env.apiOrigin}${path}`); const value = object(body); if (!value) throw malformed(); return value; }; const NEXT = /<([^>]+)>;\s*rel="next"/; // preserve configured origin to prevent pagination SSRF const nextPage = (link: string | null, origin: string) => { const raw = link ?.split(",") .map((part) => NEXT.exec(part.trim())?.[1]) .find((href): href is string => href !== undefined); if (!raw) return null; const next = URL.parse(raw); if (!next || next.origin !== origin) throw malformed(); return next.toString(); }; const PAGE_LIMIT = 100; const all = async ( call: GitHubCaller, path: string, rows: (body: unknown) => unknown = (body) => body ): Promise => { const out: Json[] = []; let url: string | null = `${call.env.apiOrigin}${path}${path.includes("?") ? "&" : "?"}per_page=100`; for (let page = 0; page < PAGE_LIMIT; page++) { const { body, link } = await send(call, url); const value = rows(body); if (!Array.isArray(value)) throw malformed(); for (const row of value) { const item = object(row); if (!item) throw malformed(); out.push(item); } url = nextPage(link, call.env.apiOrigin); if (!url) return out; } throw new Error("github pagination limit exceeded"); }; const inside = (key: string) => (body: unknown) => object(body)?.[key]; const sameHost = (value: string, host: string) => { const url = URL.parse(value); return ( url !== null && url.protocol === "https:" && url.hostname === host && !url.username && !url.password ); }; const repo = (env: GitHubEnv, row: Json, installationId?: number): GitHubRepo | null => { const host = hostOf(env.publicOrigin); const id = integer(row, "id"), name = text(row, "name"), fullName = text(row, "full_name"), cloneUrl = text(row, "clone_url"), htmlUrl = text(row, "html_url"), visibility = text(row, "visibility"), privateFlag = row.private; if ( id === null || name === null || fullName === null || cloneUrl === null || htmlUrl === null || !sameHost(cloneUrl, host) || !sameHost(htmlUrl, host) || (privateFlag !== undefined && typeof privateFlag !== "boolean") ) return null; const isPrivate = privateFlag === true || visibility === "private" || visibility === "internal"; const language = text(row, "language"), stars = integer(row, "stargazers_count"), issues = integer(row, "open_issues_count"), forks = integer(row, "forks_count"), sizeKb = integer(row, "size"), updatedAt = text(row, "pushed_at") ?? text(row, "updated_at"); return { id, name, fullName, description: text(row, "description"), cloneUrl, htmlUrl, defaultBranch: text(row, "default_branch") ?? "", visibility: visibility === "public" || visibility === "private" || visibility === "internal" ? visibility : isPrivate ? "private" : "public", private: isPrivate, installationId, language, stars, issues, forks, updatedAt, sizeKb }; }; const key = (row: Json): GitHubKey | null => { const id = integer(row, "id"), value = text(row, "key"); return id === null || value === null ? null : { id, key: value, title: text(row, "title") ?? "" }; }; const emailRow = (row: Json) => { const address = text(row, "email"); return address === null ? null : { address, primary: row.primary === true, verified: row.verified === true }; }; // noreply addresses cannot receive deliberi verification email const NOREPLY = /@(?:users\.)?noreply\.github\.com$/i; const primaryEmail = (rows: Json[]) => { const usable = rows.flatMap((row) => { const parsed = emailRow(row); return parsed === null || !parsed.verified || NOREPLY.test(parsed.address) ? [] : [parsed]; }); return (usable.find((e) => e.primary) ?? usable[0])?.address ?? null; }; export const account = async ( env: GitHubEnv, token: string, fetcher: typeof fetch = fetch ): Promise => { const call: GitHubCaller = { env, token, fetch: fetcher }; const [me, emails, keys] = await Promise.all([ one(call, "/user"), all(call, "/user/emails"), all(call, "/user/keys") ]); const login = text(me, "login"); const [owned, installations] = await Promise.all([ login === null ? [] : all(call, `/users/${encodeURIComponent(login)}/repos?type=owner`), all(call, "/user/installations", inside("installations")) ]); const granted = await Promise.all( installations.flatMap((row) => { const id = integer(row, "id"); return id === null ? [] : [ all( call, `/user/installations/${id}/repositories`, inside("repositories") ).then((rows) => rows.map((r) => repo(env, r, id))) ]; }) ); const unique = new Map( [...owned.map((row) => repo(env, row)), ...granted.flat()].flatMap((r) => r === null ? [] : [[r.id, r] as const] ) ); return { login: login ?? "", avatarUrl: text(me, "avatar_url") ?? "", bio: text(me, "bio"), blog: text(me, "blog"), email: primaryEmail(emails), emails: emails.flatMap((row) => { const parsed = emailRow(row); return parsed === null ? [] : [parsed]; }), keys: keys.flatMap((row) => { const parsed = key(row); return parsed === null ? [] : [parsed]; }), repos: [...unique.values()], publicOrigin: env.publicOrigin }; }; export const hasInstallations = async ( env: GitHubEnv, token: string, fetcher: typeof fetch = fetch ) => (await all({ env, token, fetch: fetcher }, "/user/installations", inside("installations"))) .length > 0; // pinned to avatarsOrigin to prevent SSRF export const avatarSource = async ( env: GitHubEnv, token: string, fetcher: typeof fetch = fetch ): Promise => { const me = await one({ env, token, fetch: fetcher }, "/user"); const raw = text(me, "avatar_url"); const avatar = raw === null ? null : URL.parse(raw); if (!avatar || avatar.origin !== env.avatarsOrigin || avatar.username || avatar.password) throw malformed(); // default github avatar size is ~40px without explicit size parameter avatar.searchParams.set("s", "256"); return avatar; };