import { env } from "cloudflare:workers"; import { z } from "zod"; import { all, first } from "./db"; 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; } 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 [ 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), all("SELECT name, created_at, last_used_at FROM passkeys WHERE user_id = ? ORDER BY created_at", userId), 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, ), all( `SELECT a.name || '/' || r.name AS repo, i.number, i.is_pull, i.title, i.body, i.state, i.created_at, i.updated_at FROM issues i JOIN repos r ON r.id = i.repo_id JOIN accounts a ON a.id = r.owner_id WHERE i.author_id = ?1 AND ${readable} ORDER BY i.created_at LIMIT ${limit}`, userId, ), all( `SELECT a.name || '/' || r.name AS repo, i.number, c.body, c.created_at, c.updated_at FROM comments c JOIN issues i ON i.id = c.issue_id JOIN repos r ON r.id = i.repo_id JOIN accounts a ON a.id = r.owner_id WHERE c.author_id = ?1 AND ${readable} ORDER BY c.created_at LIMIT ${limit}`, userId, ), all( `SELECT a.name || '/' || r.name AS repo, i.number, v.state, v.body, v.created_at FROM reviews v JOIN issues i ON i.id = v.issue_id JOIN repos r ON r.id = i.repo_id JOIN accounts a ON a.id = r.owner_id WHERE v.author_id = ?1 AND ${readable} ORDER BY v.created_at LIMIT ${limit}`, userId, ), all( `SELECT a.name || '/' || r.name AS repo, 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 JOIN repos r ON r.id = i.repo_id JOIN accounts a ON a.id = r.owner_id WHERE c.author_id = ?1 AND ${readable} ORDER BY c.created_at LIMIT ${limit}`, userId, ), all( `SELECT a.name || '/' || r.name AS repo, d.number, d.title, d.body, d.created_at, d.updated_at FROM discussions d JOIN repos r ON r.id = d.repo_id JOIN accounts a ON a.id = r.owner_id WHERE d.author_id = ?1 AND ${readable} ORDER BY d.created_at LIMIT ${limit}`, userId, ), all( `SELECT a.name || '/' || r.name AS repo, d.number, c.body, c.created_at, c.updated_at FROM discussion_comments c JOIN discussions d ON d.id = c.discussion_id JOIN repos r ON r.id = d.repo_id JOIN accounts a ON a.id = r.owner_id WHERE c.author_id = ?1 AND ${readable} ORDER BY c.created_at LIMIT ${limit}`, userId, ), all( `SELECT a.name || '/' || r.name AS repo, l.tag, l.name, l.body, l.draft, l.prerelease, l.created_at FROM releases l JOIN repos r ON r.id = l.repo_id JOIN accounts a ON a.id = r.owner_id WHERE l.author_id = ?1 AND ${readable} ORDER BY l.created_at LIMIT ${limit}`, userId, ), 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, ), first( `SELECT email_participating, email_watching, web_participating, web_watching, updated_at FROM notification_settings WHERE user_id = ?`, userId, ), all("SELECT category, web, email, digest, updated_at FROM notification_category_prefs WHERE user_id = ?", userId), first("SELECT plan, spending_limit_cents FROM account_billing WHERE account_id = ?", userId), first( `SELECT subscription_status, current_period_end, cancel_at_period_end FROM billing_customers WHERE account_id = ?`, userId, ), 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)); }