import { 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(); export const fetchGitHubAccount = async ( fetcher: typeof fetch = fetch ): Promise => { 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 hardcoded const 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 { canonical: string; candidates: T[]; conflictsWithExisting: boolean; hasCollision: boolean; } export const findRepoCollisions = ( candidates: Iterable, existing: Iterable = [] ): Map> => { const existingSet = new Set( Array.from(existing).flatMap((item) => { try { return [canonicalRepoName(item)]; } catch { return []; } }) ); const groups = new Map(); 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 => { 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 => { 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 => { 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(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(); 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(); 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 }; };