import { env } from "cloudflare:workers"; import { z } from "zod"; import { all, first } from "./db"; import { accountDb, repoDb } from "./scoped-db"; import { forEachScope } from "./account-scope"; import { randomId } from "./crypto"; import { notFoundError, HttpError } from "./errors"; import { repoReadableSql } from "./repos"; import { listSigningKeys } from "./security/signatures"; import type { Viewer } from "~/lib/types"; /** One archive per user per day; the archive itself lives in KV for the same window and then expires. */ const EXPORT_TTL_SECONDS = 24 * 60 * 60; /** Authored issues/comments per kind; a busier account gets a truncated flag rather than a timeout. */ export const EXPORT_ROW_CAP = 5000; const MAX_EXPORT_BYTES = 20 * 1024 * 1024; const HTTP_TOO_MANY = 429; const exportMeta = z.object({ id: z.string(), createdAt: z.number(), bytes: z.number() }); export type ExportMeta = z.infer & { expiresAt: number }; const metaKey = (userId: string) => `account-export:${userId}`; const dataKey = (userId: string, id: string) => `account-export:${userId}:${id}`; function withExpiry(meta: z.infer): ExportMeta { return { ...meta, expiresAt: meta.createdAt + EXPORT_TTL_SECONDS * 1000 }; } export async function currentExport(userId: string): Promise { const parsed = exportMeta.safeParse(await env.KV.get(metaKey(userId), "json")); if (!parsed.success) return null; const meta = withExpiry(parsed.data); return meta.expiresAt > Date.now() ? meta : null; } /** Per-repository queries for authored content (`?1` the user, `?2` the repository), run in its RepoState. */ const AUTHORED_SQL = { issues: `SELECT i.number, i.is_pull, i.title, i.body, i.state, i.created_at, i.updated_at FROM issues i WHERE i.author_id = ?1 AND i.repo_id = ?2 ORDER BY i.created_at`, comments: `SELECT i.number, c.body, c.created_at, c.updated_at FROM comments c JOIN issues i ON i.id = c.issue_id WHERE c.author_id = ?1 AND i.repo_id = ?2 ORDER BY c.created_at`, reviews: `SELECT i.number, v.state, v.body, v.created_at FROM reviews v JOIN issues i ON i.id = v.issue_id WHERE v.author_id = ?1 AND i.repo_id = ?2 ORDER BY v.created_at`, reviewComments: `SELECT i.number, c.path, c.line, c.body, c.created_at, c.updated_at FROM review_comments c JOIN issues i ON i.id = c.issue_id WHERE c.author_id = ?1 AND i.repo_id = ?2 ORDER BY c.created_at`, discussions: `SELECT d.number, d.title, d.body, d.created_at, d.updated_at FROM discussions d WHERE d.author_id = ?1 AND d.repo_id = ?2 ORDER BY d.created_at`, discussionComments: `SELECT d.number, c.body, c.created_at, c.updated_at FROM discussion_comments c JOIN discussions d ON d.id = c.discussion_id WHERE c.author_id = ?1 AND d.repo_id = ?2 ORDER BY c.created_at`, releases: `SELECT l.tag, l.name, l.body, l.draft, l.prerelease, l.created_at FROM releases l WHERE l.author_id = ?1 AND l.repo_id = ?2 ORDER BY l.created_at`, } as const; type AuthoredKind = keyof typeof AUTHORED_SQL; function capped(rows: T[]): { items: T[]; truncated: boolean } { return { items: rows.slice(0, EXPORT_ROW_CAP), truncated: rows.length > EXPORT_ROW_CAP }; } /** * The personal-data archive behind Settings → Export: profile, emails, public key material, token metadata * (never hashes), authored issues and comments on repositories the user can still read, stars, follows, * notification settings and a billing summary. Repository contents are excluded; Git clone covers those. */ export async function buildAccountExport(userId: string) { const readable = repoReadableSql("r", "?1"); const limit = EXPORT_ROW_CAP + 1; const own = accountDb(userId); // Authored content lives in each repository's RepoState: query every repository the user can still read. const readableRepos = await all<{ id: string; full_name: string }>( `SELECT r.id, a.name || '/' || r.name AS full_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE ${readable}`, userId, ); const perRepo = await forEachScope( readableRepos.map((repo) => repo.id), async (repoId) => { const db = repoDb(repoId); const out: Partial[]>> = {}; for (const kind of Object.keys(AUTHORED_SQL) as AuthoredKind[]) { out[kind] = await db.all>(`${AUTHORED_SQL[kind]} LIMIT ${limit}`, userId, repoId); } return out; }, ); const repoNames = readableRepos.map((repo) => repo.full_name); const authored = async (kind: AuthoredKind) => perRepo .flatMap((rows, index) => (rows[kind] ?? []).map((row): Record => ({ repo: repoNames[index], ...row })), ) .sort((left, right) => Number(left.created_at) - Number(right.created_at)) .slice(0, limit); const [ profile, emails, signingKeys, passkeys, tokens, issues, comments, reviews, reviewComments, discussions, discussionComments, releases, stars, following, followers, orgs, notificationSettings, notificationCategories, plan, subscription, credits, ] = await Promise.all([ first<{ name: string; display_name: string | null; bio: string | null; website: string | null; created_at: number; email: string; email_verified_at: number | null; github_id: string | null; totp_enabled_at: number | null; terms_version: string | null; terms_accepted_at: number | null; }>( `SELECT a.name, a.display_name, a.bio, a.website, a.created_at, u.email, u.email_verified_at, u.github_id, u.totp_enabled_at, u.terms_version, u.terms_accepted_at FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.id = ?`, userId, ), all("SELECT email, verified_at, created_at FROM user_emails WHERE user_id = ? ORDER BY created_at", userId), listSigningKeys(userId), own.all("SELECT name, created_at, last_used_at FROM passkeys WHERE user_id = ? ORDER BY created_at", userId), own.all( `SELECT name, token_prefix, scopes, permissions, all_repos, audience, created_at, last_used_at, expires_at FROM access_tokens WHERE user_id = ? ORDER BY created_at`, userId, ), authored("issues"), authored("comments"), authored("reviews"), authored("reviewComments"), authored("discussions"), authored("discussionComments"), authored("releases"), all( `SELECT a.name || '/' || r.name AS repo, s.created_at FROM stars s JOIN repos r ON r.id = s.repo_id JOIN accounts a ON a.id = r.owner_id WHERE s.user_id = ?1 AND ${readable} ORDER BY s.created_at`, userId, ), all( `SELECT a.name, f.created_at FROM follows f JOIN accounts a ON a.id = f.followee_id WHERE f.follower_id = ? ORDER BY f.created_at`, userId, ), all( `SELECT a.name, f.created_at FROM follows f JOIN accounts a ON a.id = f.follower_id WHERE f.followee_id = ? ORDER BY f.created_at`, userId, ), all( `SELECT a.name, m.role, m.created_at FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ? ORDER BY a.name`, userId, ), own.first( `SELECT email_participating, email_watching, web_participating, web_watching, updated_at FROM notification_settings WHERE user_id = ?`, userId, ), own.all( "SELECT category, web, email, digest, updated_at FROM notification_category_prefs WHERE user_id = ?", userId, ), own.first("SELECT plan, spending_limit_cents FROM account_billing WHERE account_id = ?", userId), own.first( `SELECT subscription_status, current_period_end, cancel_at_period_end FROM billing_customers WHERE account_id = ?`, userId, ), own.all("SELECT meter, balance FROM credit_balances WHERE account_id = ? ORDER BY meter", userId), ]); if (!profile) throw notFoundError("Account not found"); return { format: "gitfor-account-export", version: 1, generatedAt: new Date().toISOString(), profile, emails, signingKeys: signingKeys.map(({ kind, title, fingerprint, createdAt }) => ({ kind, title, fingerprint, createdAt, })), passkeys, tokens, organizations: orgs, issues: capped(issues), comments: capped(comments), reviews: capped(reviews), reviewComments: capped(reviewComments), discussions: capped(discussions), discussionComments: capped(discussionComments), releases: capped(releases), stars, following, followers, notifications: { settings: notificationSettings, categories: notificationCategories }, billing: { plan, subscription, credits }, }; } /** Builds a fresh archive unless one was made (or is being made) in the last 24 hours. */ export async function requestAccountExport(viewer: Viewer): Promise { const existing = await currentExport(viewer.id); if (existing) { throw new HttpError(HTTP_TOO_MANY, "You can export your data once a day; download the existing archive instead"); } const meta = { id: randomId(), createdAt: Date.now(), bytes: 0 }; // Claim the day's slot before the slow build so a concurrent request sees it and backs off. await env.KV.put(metaKey(viewer.id), JSON.stringify(meta), { expirationTtl: EXPORT_TTL_SECONDS }); try { const body = JSON.stringify(await buildAccountExport(viewer.id), null, 2); const bytes = new TextEncoder().encode(body).byteLength; if (bytes > MAX_EXPORT_BYTES) throw new HttpError(413, "Your export is too large; contact the operator for a copy"); await env.KV.put(dataKey(viewer.id, meta.id), body, { expirationTtl: EXPORT_TTL_SECONDS }); const done = { ...meta, bytes }; await env.KV.put(metaKey(viewer.id), JSON.stringify(done), { expirationTtl: EXPORT_TTL_SECONDS }); return withExpiry(done); } catch (error) { await env.KV.delete(metaKey(viewer.id)); throw error; } } /** The archive body, only for its owner and only while it has not expired. */ export async function readAccountExport(viewer: Viewer, id: string): Promise { const meta = await currentExport(viewer.id); if (!meta || meta.id !== id) return null; return env.KV.get(dataKey(viewer.id, id)); } /** Account deletion drops a pending archive at once instead of leaving it for the TTL. */ export async function discardAccountExport(userId: string): Promise { const meta = await currentExport(userId); if (meta) await env.KV.delete(dataKey(userId, meta.id)); await env.KV.delete(metaKey(userId)); }