import type { GitHubAccount, GitHubKey, GitHubRepo } from "#lib/github.ts";
import { type GitHubEnv, hostOf } from "#lib/server/github/env.ts";
import type { Fetch } from "#lib/fetch.ts";
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: 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 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<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 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: 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()].sort((a, b) => a.name.localeCompare(b.name)),
publicOrigin: env.publicOrigin
};
};
export const hasInstallations = async (env: GitHubEnv, token: string, fetcher: 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: 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;
};