Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249import 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<string, unknown>;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<Json> => { 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 SSRFconst 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<Json[]> => { 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 emailconst 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<GitHubAccount> => { 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 SSRFexport const avatarSource = async ( env: GitHubEnv, token: string, fetcher: typeof fetch = fetch): Promise<URL> => { 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;};