kanban / src /lib /server /githubBackup.ts
Leon4gr45's picture
Add GitHub backup: auto-backup + restore of resumes (part 2)
4929a1d verified
Raw
History Blame Contribute Delete
4.4 kB
// 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<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>;
};
// 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<string, unknown>
): 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<BackupPayload | null> => {
const current = await getCurrentFile(cfg);
if (current.content === null) return null;
try {
return JSON.parse(current.content) as BackupPayload;
} catch {
return null;
}
};