Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455import { ok } from "@atcute/client";import { mainSchema as listRecordsSchema } from "@atcute/atproto/types/repo/listRecords";import { isDid, type Did, type Nsid } from "@atcute/lexicons/syntax";import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { createClient, serviceDidForHost } from "$lib/auth/agent";import { createOptimisticRepoRecord, validateRepoName } from "$lib/api/repoCreate";import { createMigrationTask, type MigrationTask } from "$lib/api/migrator";import { uploadProfileAvatar } from "$lib/api/profile";import { createRecord, putRecord, readRecord } from "$lib/api/write";import { createPubKey } from "$lib/api/settings";import { addEmail, listEmails } from "$lib/api/emails";import { createDeliberiClient } from "$lib/api/deliberi";import type { GitHubAccount, GitHubRepo } from "$lib/github";import type { ProfileRecord } from "$lib/api/records";
const ACCOUNT_CACHE_MS = 30_000;const accountCache = new WeakMap<typeof fetch, { account: GitHubAccount; at: number }>();
export const fetchGitHubAccount = async ( fetcher: typeof fetch = fetch): Promise<GitHubAccount | null> => { const cached = accountCache.get(fetcher); if (cached && Date.now() - cached.at < ACCOUNT_CACHE_MS) return cached.account; const response = await fetcher("/_internal/github", { cache: "no-store" }); if (response.status === 401) { accountCache.delete(fetcher); return null; } if (!response.ok) throw new Error("Could not load your GitHub account. Try connecting again."); const account = (await response.json()) as GitHubAccount; accountCache.set(fetcher, { account, at: Date.now() }); return account;};
export const invalidateGitHubAccount = (fetcher: typeof fetch = fetch) => accountCache.delete(fetcher);
const PROFILE_COLLECTION = "sh.tangled.actor.profile" as Nsid;const PUBLIC_KEY_COLLECTION = "sh.tangled.publicKey" as Nsid;
const GITHUB_REPO_GRAMMAR = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
const messageOf = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause);
// localinfra serves a github stub on its own origin; github.com cannot be hardcodedconst hostOf = (origin: string): string => { let parsed: URL; try { parsed = new URL(origin); } catch { throw new Error(`Invalid GitHub origin: "${origin}" is not a URL.`); } if (parsed.protocol !== "https:") { throw new Error(`Invalid GitHub origin: protocol must be https.`); } return parsed.hostname.toLowerCase();};
export const validateGitHubRepoUrl = ( urlString: string, expectedFullName: string, kind: "htmlUrl" | "cloneUrl", origin: string): string => { if (!GITHUB_REPO_GRAMMAR.test(expectedFullName)) { throw new Error(`Invalid GitHub fullName: "${expectedFullName}".`); } let parsed: URL; try { parsed = new URL(urlString); } catch { throw new Error(`Invalid GitHub ${kind}: malformed URL.`); } if (parsed.protocol !== "https:") { throw new Error(`Invalid GitHub ${kind}: protocol must be https.`); } const allowedHost = hostOf(origin); if (parsed.hostname.toLowerCase() !== allowedHost) { throw new Error(`Invalid GitHub ${kind}: host must be ${allowedHost}.`); } if (parsed.username || parsed.password) { throw new Error(`Invalid GitHub ${kind}: credentials are not allowed.`); } if (parsed.port !== "") { throw new Error(`Invalid GitHub ${kind}: non-default port is not allowed.`); } if (parsed.search !== "") { throw new Error(`Invalid GitHub ${kind}: query parameters are not allowed.`); } if (parsed.hash !== "") { throw new Error(`Invalid GitHub ${kind}: hash fragments are not allowed.`); }
const allowedPaths = kind === "cloneUrl" ? [`/${expectedFullName}.git`, `/${expectedFullName}`] : [`/${expectedFullName}`];
if (!allowedPaths.includes(parsed.pathname)) { throw new Error( `Invalid GitHub ${kind}: pathname must match repository "${expectedFullName}".` ); }
return parsed.toString();};
export const canonicalRepoName = (rawName: string): string => validateRepoName(rawName).toLowerCase();
interface RepoCollision<T = string> { canonical: string; candidates: T[]; conflictsWithExisting: boolean; hasCollision: boolean;}
export const findRepoCollisions = <T extends string | { name: string }>( candidates: Iterable<T>, existing: Iterable<string> = []): Map<string, RepoCollision<T>> => { const existingSet = new Set( Array.from(existing).flatMap((item) => { try { return [canonicalRepoName(item)]; } catch { return []; } }) );
const groups = new Map<string, T[]>(); for (const candidate of candidates) { const rawName = typeof candidate === "string" ? candidate : candidate.name; const canonical = canonicalRepoName(rawName); const bucket = groups.get(canonical); if (bucket) { bucket.push(candidate); } else { groups.set(canonical, [candidate]); } }
return new Map( Array.from(groups, ([canonical, items]) => { const conflictsWithExisting = existingSet.has(canonical); return [ canonical, { canonical, candidates: items, conflictsWithExisting, hasCollision: items.length > 1 || conflictsWithExisting } ]; }) );};
interface ImportGitHubRepoInput { ownerDid: Did; ownerHandle: string; knot: string; spindle?: string; repo: GitHubRepo; name?: string; description?: string;}
interface ImportUrlInput { sourceUrl: string; name: string; description?: string; knot: string; ownerDid?: Did; ownerHandle?: string; spindle?: string;}
const knotDidOf = (knot: string): Did => { const did = knot.startsWith("did:") ? knot : serviceDidForHost(knot); if (!isDid(did)) throw new Error(`${knot} is not a did`); return did;};
type Uri = `${string}:${string}`;
const validateUrlSource = (raw: string): Uri => { let parsed: URL; try { parsed = new URL(raw.trim()); } catch { throw new Error("That is not a URL."); } if (parsed.protocol !== "https:") throw new Error("The source URL must be https."); if (parsed.username || parsed.password) throw new Error("The source URL must not carry credentials."); if (parsed.port !== "" && parsed.port !== "443") throw new Error("The source URL must not use a custom port."); if (parsed.pathname.replace(/\/+$/, "").split("/").filter(Boolean).length === 0) throw new Error("The source URL must name a repository."); return parsed.toString() as Uri;};
export interface StartedMigration { taskId: string; name: string; jobId?: string;}
export const importFromUrl = async ( agent: OAuthUserAgent, migratorUrl: string, input: ImportUrlInput, options?: { fetch?: typeof globalThis.fetch; signal?: AbortSignal; bobbinUrl?: string }): Promise<StartedMigration> => { const sourceUrl = validateUrlSource(input.sourceUrl); const name = validateRepoName(input.name); const description = input.description?.trim() || undefined;
if (options?.bobbinUrl && input.ownerDid && input.ownerHandle) { await createOptimisticRepoRecord(agent, options.bobbinUrl, { ownerDid: input.ownerDid, ownerHandle: input.ownerHandle, name, knot: input.knot, spindle: input.spindle, description, source: { kind: "import", url: sourceUrl } }); }
const task = await createMigrationTask( agent, migratorUrl, crypto.randomUUID(), [ { name, knotDid: knotDidOf(input.knot), sourceUrl, ...(description !== undefined ? { description } : {}) } ], options ); const first = task.jobs[0]; return { taskId: task.id, name: first?.name ?? name, jobId: first?.id };};
export const importGitHubRepos = async ( agent: OAuthUserAgent, migratorUrl: string, origin: string, inputs: ImportGitHubRepoInput[], options?: { fetch?: typeof globalThis.fetch; signal?: AbortSignal; bobbinUrl?: string }): Promise<MigrationTask> => { if (inputs.length === 0) throw new Error("Select at least one repository to import."); const jobs = []; for (const input of inputs) { const cloneUrl = validateGitHubRepoUrl( input.repo.cloneUrl, input.repo.fullName, "cloneUrl", origin ); const name = validateRepoName(input.name ?? input.repo.name); const description = input.description?.trim() || undefined;
if (options?.bobbinUrl) { await createOptimisticRepoRecord(agent, options.bobbinUrl, { ownerDid: input.ownerDid, ownerHandle: input.ownerHandle, name, knot: input.knot, spindle: input.spindle, description, source: { kind: "import", url: cloneUrl } }); }
jobs.push({ name, knotDid: knotDidOf(input.knot), sourceUrl: validateUrlSource(cloneUrl), ...(input.repo.private === true ? { private: true } : {}), ...(description !== undefined ? { description } : {}) }); } return createMigrationTask(agent, migratorUrl, crypto.randomUUID(), jobs, options);};
const normalizeSshKey = (rawKey: string): string | null => { const parts = rawKey.trim().split(/\s+/); if (parts.length < 2) return null; const [algorithm, base64Blob] = parts; if (!/^[a-zA-Z0-9@._-]+$/.test(algorithm)) return null; if (!/^[A-Za-z0-9+/=]+$/.test(base64Blob)) return null; return `${algorithm} ${base64Blob}`;};
interface ImportGitHubProfileEndpoints { bobbinUrl?: string; deliberiUrl: string; fetch?: typeof globalThis.fetch;}
interface ImportGitHubProfileOptions { avatar: boolean; keys: boolean; email: boolean;}
interface ImportedGitHubKey { rkey: string; name: string; key: string;}
interface ImportGitHubProfileResult { imported: string[]; errors: string[]; keys: ImportedGitHubKey[];}
export const importGitHubProfile = async ( agent: OAuthUserAgent, endpoints: ImportGitHubProfileEndpoints, account: GitHubAccount, options: ImportGitHubProfileOptions): Promise<ImportGitHubProfileResult> => { const fetchFn = endpoints.fetch ?? globalThis.fetch; const imported: string[] = []; const errors: string[] = [];
if (options.avatar) { try { const res = await fetchFn("/_internal/github/avatar"); if (!res.ok) { throw new Error(`failed to fetch avatar (${res.status})`); } const rawBlob = await res.blob(); const contentType = res.headers.get("content-type")?.split(";")[0]?.trim(); const image = contentType && !rawBlob.type ? new Blob([rawBlob], { type: contentType }) : rawBlob; const avatarBlob = await uploadProfileAvatar(agent, image);
const existing = await readRecord<ProfileRecord>(agent, PROFILE_COLLECTION, "self"); const existingProfile = existing?.value; const existingCid = existing?.written?.cid;
const record: ProfileRecord = { $type: "sh.tangled.actor.profile", bluesky: false, ...(existingProfile ?? {}), avatar: avatarBlob };
if (existingProfile) { await putRecord(agent, PROFILE_COLLECTION, "self", record, existingCid); } else { await createRecord(agent, PROFILE_COLLECTION, record, "self"); } imported.push("avatar"); } catch (cause) { errors.push(`Failed to import avatar: ${messageOf(cause)}`); } }
const writtenKeys: ImportedGitHubKey[] = [];
if (options.keys) { try { const rpc = createClient(agent); const existingTokens = new Set<string>(); let cursor: string | undefined;
do { const page = await ok( rpc.call(listRecordsSchema, { params: { repo: agent.sub, collection: PUBLIC_KEY_COLLECTION, limit: 100, ...(cursor ? { cursor } : {}) } }) ); for (const rec of page.records) { const val = rec.value as { key?: unknown }; if (typeof val?.key === "string") { const token = normalizeSshKey(val.key); if (token) existingTokens.add(token); } } cursor = page.cursor; } while (cursor);
const seenInBatch = new Set<string>(); for (const key of account.keys) { const rawKey = key.key?.trim() ?? ""; const token = normalizeSshKey(rawKey); if (!token) { errors.push(`Invalid SSH key format for key ${key.title || key.id}.`); continue; } if (existingTokens.has(token) || seenInBatch.has(token)) { continue; } seenInBatch.add(token);
try { const title = key.title?.trim() || `github-${key.id}`; const written = await createPubKey(agent, title, rawKey); writtenKeys.push({ rkey: written.rkey, name: title, key: rawKey }); existingTokens.add(token); imported.push(`key:${key.id}`); } catch (cause) { errors.push(`Failed to import key ${key.title || key.id}: ${messageOf(cause)}`); } } } catch (cause) { errors.push(`Failed to load existing keys: ${messageOf(cause)}`); } }
if (options.email) { if (!account.email || !account.email.trim()) { errors.push("GitHub account does not have an email address."); } else { const targetEmail = account.email.trim(); try { const deliberi = createDeliberiClient({ deliberiUrl: endpoints.deliberiUrl, agent, fetch: fetchFn }); const { emails } = await listEmails(deliberi); const normalizedTarget = targetEmail.toLowerCase(); const exists = emails.some((e) => e.address.toLowerCase() === normalizedTarget);
if (!exists) { await addEmail(deliberi, targetEmail); imported.push(`email:${targetEmail} (verification required)`); } } catch (cause) { errors.push(`Failed to import email ${targetEmail}: ${messageOf(cause)}`); } } }
return { imported, errors, keys: writtenKeys };};