// Server-side GitHub backup for resumes. // // Commits the full resume set to a private GitHub repo via the Contents API so // resumes survive a browser-cache wipe. Configured entirely through env vars so // no secrets live in the repo: // // GITHUB_BACKUP_TOKEN fine-grained PAT with Contents:read/write on the backup repo // GITHUB_BACKUP_REPO "owner/repo" (e.g. JsonLord/magic-resume-backup) // GITHUB_BACKUP_BRANCH branch to commit to (default: main) // GITHUB_BACKUP_PATH file path in the repo (default: data/resumes.json) // // When the token/repo are absent the API route reports "not configured" instead // of erroring, so the app runs fine without backup enabled. export type BackupConfig = { token: string; repo: string; branch: string; path: string; }; export const getBackupConfig = (): BackupConfig | null => { const token = process.env.GITHUB_BACKUP_TOKEN; const repo = process.env.GITHUB_BACKUP_REPO; if (!token || !repo) return null; return { token, repo, branch: process.env.GITHUB_BACKUP_BRANCH || "main", path: process.env.GITHUB_BACKUP_PATH || "data/resumes.json", }; }; const gh = (cfg: BackupConfig, path: string, init: RequestInit = {}) => fetch(`https://api.github.com${path}`, { ...init, headers: { Authorization: `Bearer ${cfg.token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "magic-resume-backup", ...(init.headers || {}), }, }); // contents API wants a raw "/" in the path, not %2F const contentsUrl = (cfg: BackupConfig) => `/repos/${cfg.repo}/contents/${cfg.path .split("/") .map(encodeURIComponent) .join("/")}`; const toBase64 = (text: string) => Buffer.from(text, "utf8").toString("base64"); const fromBase64 = (b64: string) => Buffer.from(b64, "base64").toString("utf8"); type CurrentFile = { sha: string; content: string } | { sha: null; content: null }; const getCurrentFile = async (cfg: BackupConfig): Promise => { const res = await gh(cfg, `${contentsUrl(cfg)}?ref=${encodeURIComponent(cfg.branch)}`); if (res.status === 404) return { sha: null, content: null }; if (!res.ok) { throw new Error(`GitHub GET ${res.status}: ${(await res.text()).slice(0, 300)}`); } const json = (await res.json()) as { sha: string; content: string }; return { sha: json.sha, content: fromBase64(json.content.replace(/\n/g, "")) }; }; export type BackupPayload = { version: number; updatedAt: string; count: number; resumes: Record; }; // Commit the given resumes. Skips the commit when content is byte-identical to // what's already stored (avoids a stream of no-op commits on every keystroke). export const backupResumes = async ( cfg: BackupConfig, resumes: Record ): Promise<{ committed: boolean; commit?: string }> => { const payload: BackupPayload = { version: 1, updatedAt: new Date().toISOString(), count: Object.keys(resumes).length, resumes, }; // Stable serialization: drop the volatile updatedAt when comparing so an // unchanged resume set doesn't produce a new commit just because time passed. const comparable = (text: string) => text.replace(/"updatedAt":\s*"[^"]*",?\s*/, ""); const text = JSON.stringify(payload, null, 2); const current = await getCurrentFile(cfg); if (current.content !== null && comparable(current.content) === comparable(text)) { return { committed: false }; } const res = await gh(cfg, contentsUrl(cfg), { method: "PUT", body: JSON.stringify({ message: `backup: ${payload.count} resume(s) @ ${payload.updatedAt}`, content: toBase64(text), branch: cfg.branch, ...(current.sha ? { sha: current.sha } : {}), }), }); if (!res.ok) { throw new Error(`GitHub PUT ${res.status}: ${(await res.text()).slice(0, 300)}`); } const json = (await res.json()) as { commit?: { sha?: string } }; return { committed: true, commit: json.commit?.sha }; }; // Read the latest backup for restore. Returns null when nothing is backed up yet. export const readBackup = async ( cfg: BackupConfig ): Promise => { const current = await getCurrentFile(cfg); if (current.content === null) return null; try { return JSON.parse(current.content) as BackupPayload; } catch { return null; } };