| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 || {}), |
| }, |
| }); |
|
|
| |
| 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<CurrentFile> => { |
| 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<string, unknown>; |
| }; |
|
|
| |
| |
| export const backupResumes = async ( |
| cfg: BackupConfig, |
| resumes: Record<string, unknown> |
| ): Promise<{ committed: boolean; commit?: string }> => { |
| const payload: BackupPayload = { |
| version: 1, |
| updatedAt: new Date().toISOString(), |
| count: Object.keys(resumes).length, |
| resumes, |
| }; |
| |
| |
| 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 }; |
| }; |
|
|
| |
| export const readBackup = async ( |
| cfg: BackupConfig |
| ): Promise<BackupPayload | null> => { |
| const current = await getCurrentFile(cfg); |
| if (current.content === null) return null; |
| try { |
| return JSON.parse(current.content) as BackupPayload; |
| } catch { |
| return null; |
| } |
| }; |
|
|