Files
blog/scripts/backup-bundle.mjs
T
zqlit d3da972c3b feat(editor-api): 线上容器切「CNB 主 + Gitea 备」双推;备份补 fetch 步骤
用户定案:「editor-api 容器 采用 cnb主仓 gitea备份仓 openlist文件备份」。
三层各有各的失效模式,不能互相替代:
  ① 主仓 origin = CNB          —— 源码 + 触发构建,依赖 CNB 平台
  ② 代码辅仓 gitea = 自建 Gitea —— 不受第三方平台规则约束,可 clone / 按提交追溯
  ③ 离线备份 = F50/OpenList     —— 单一加密文件,平台全挂也能恢复

一、线上容器实测切双推(国内机 119.29.215.187)

先探测出两个关键事实:
  · 国内机**能**直连自建 Gitea(/api/v1/version → 200,0.38s)
  · 国内机**够不到**家里的 OpenList(192.168.0.1 私有地址,6s 超时)
    → 「文件备份」这一层只能由家庭机发起,国内机做不到(下面的 fetch 修复正因此

改动:
  · /srv/blog 停在 432cf5e3,落后 origin/main 10 个提交且是其后代
    → git merge --ff-only 追平到 448b898e,**未强推**
  · 挂 gitea 远端(凭据编在 URL 里,同本机做法)
  · .env:PUSH_REMOTES=origin → origin,gitea;追加 GITEA_URL/USER/PASS
  · docker-compose.yml 默认值同步为 ${PUSH_REMOTES:-origin,gitea}
  · docker compose up -d --build 重建

验证(全部实测通过):
  · 启动日志 [editor-api] 分支 main 推送远端 origin, gitea
  · curl /health → "remotes":["origin","gitea"]
  · 容器内推 origin ✅ / 推 gitea ✅(推临时分支 → ls-remote 确认 → 删分支,无残留)
  · 三处 main 对齐 448b898e(本地 / origin / gitea);工作区干净

二、备份侧:修掉一个静默漏洞 —— 打包前必须先 fetch

git bundle create --all 取的是**本地已知** ref,其中 refs/remotes/origin/main
停在上次 fetch/pull 的位置。而这份备份跑在**家里**那台机器上,工作区并不会
随写作后台(editor-api)的发布自动更新。所以原先是:后台新发的文章**一篇都不
在备份里**,而备份照样报「成功」—— 失败是静默的。

  · backup-bundle.mjs 打包前插入 git fetch --all --tags --prune
    - 失败**不致命**(离线也得出得来备份)→ 降级为「用本地已有 ref 打包」+ 显著告警
    - 新增 --no-fetch 可跳过;步骤号 1/6..6/6 → 0/7..6/7
    - 新增一行 `快照 origin/main = <sha> <时间> <标题>` —— 恢复时第一件要确认的就是它
  · backup-run.mjs:头部补三层说明;失败邮件的「常见原因」加上 fetch 降级这一条

验证:fetch 3s 拉完 origin+gitea → 打包 3.6s → 加密 1.7s → 上传 19.2s(31.5MB/s)
      → 读回 sha256 一致,快照 = 448b898e

三、bootstrap.sh:修掉另一个静默陷阱

脚本每次都会**重写** .env,而 .env 是 GITEA_PASS 的唯一落点 →
「重跑一次但没现给 GITEA_PASS」会把辅仓推送**静默关掉**(PUSH_REMOTES 降级成
origin),不报错、不提示,直到要恢复时才发现 Gitea 早就没在同步。
  · 改为:命令行没给就从旧 .env 里捡回来(显式传新值仍优先)
  · 并把 GITEA_URL/USER/PASS 也写进 .env(容器不读这几个键,仅作复用锚点)
  · 该逻辑用 4 个用例离线验证(有/无 .env、显式覆盖、.env 里没 PASS),
    并确认 set -euo pipefail 下不会被 [ -n ] && cmd 这类写法误触发退出

四、文档

· 架构总览.md:§1.2 地址地图加 editor-api 行;§2 旅程图补「两条推送入口同一套语义」;
  §5.2 加「线上容器的推送目标」段(含验证方法与 pull 源只有 origin 的铁律);
  §5.6 加「打包前必须 git fetch」+「覆盖范围」表(诚实列出够不到的部分);
  §6 待办 #4/#10 更新、新增 #12
· editor-api/README.md:新增「落盘:写一次,存三处」
· README.md:推送说明补线上后台那一路与 fetch 说明;脚本表更新
2026-10-06 22:27:36 +08:00

370 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 整仓备份 → 单个 bundle 文件 → AES-256-GCM 加密 → WebDAV 上传到 OpenList。
*
* 为什么是 bundle 而不是直接推 git:
* WebDAV 不支持原子的 rename/lock,把 bare repo 挂上去直接 `git push` 会让对象写坏
* (表面成功、实际随机损坏,可能几个月后才发现)。bundle 是单文件顺序写,安全。
*
* 为什么默认加密:
* 本仓库历史里含 .env、TLS 私钥、GITEA_SECRETS.md。目标介质是手机内部存储,
* 未加密等于把密钥明文放在一台可能被刷机/丢失/他人访问的设备上。
*
* 为什么不用 gpg:
* 本机 gpg 2.4.9 在 Windows 下已损坏(反复 `removing stale lockfile`,node spawn 直接 EBUSY)。
* 改用 Node 内置 crypto 的 AES-256-GCM:零外部依赖、带认证标签(能检测篡改/截断)、
* 可流式处理 600 MB 不爆内存。加密格式见下方注释,解密由本脚本 `--decrypt` 完成。
*
* ★ 打包前先 `git fetch --all`(2026-10-06 补):
* `git bundle create --all` 取的是**本地已知**的 refs —— 其中
* `refs/remotes/origin/main` 停在上一次 fetch/pull 的位置。
* 而写作后台(editor-api)发的文章只落在 CNB 主仓,本机这份工作区
* 并不会自动跟着更新;不 fetch 就打 bundle,等于把**过期的快照**当成备份,
* 后台最近发的文章一份都不在里面 —— 而且失败是静默的(备份照样"成功")。
* 所以这里先 fetch,再打包。fetch 失败**不致命**(离线也得出得来备份),
* 只降级为「用本地已有 ref 打包」并显著告警。
*
* 用法:
* node scripts/backup-bundle.mjs # fetch + 加密 + 上传 + 比对字节数
* node scripts/backup-bundle.mjs --verify # 额外下载回来比对 sha256(慢,但端到端最可靠)
* node scripts/backup-bundle.mjs --dry # 只打包加密,不上传
* node scripts/backup-bundle.mjs --keep 7 # 远端保留最近 7 份(默认)
* node scripts/backup-bundle.mjs --no-fetch # 跳过 fetch(离线/调试用)
* node scripts/backup-bundle.mjs --no-encrypt # 不加密(仅当历史里的敏感文件已洗净)
* node scripts/backup-bundle.mjs --decrypt <文件> [--out x.bundle] # 解密(恢复用)
* node scripts/backup-bundle.mjs --list # 列出远端现有备份
*
* 配置(按此顺序查找,先找到的生效):
* 1. 环境变量 OPENLIST_URL / OPENLIST_USER / OPENLIST_PASS / BACKUP_PASSPHRASE / OPENLIST_DIR
* 2. ~/.openlist-backup.env
* 3. .workbuddy-backup/openlist-backup.env (已在 .gitignore 内)
*
* 加密文件布局: magic(8) | salt(16) | iv(12) | 密文(...) | GCM tag(16)
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import http from 'node:http';
import https from 'node:https';
import { execFileSync } from 'node:child_process';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const MAGIC = Buffer.from('BLOGBKP1', 'ascii');
const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 128 * 1024 * 1024 };
const HEAD = MAGIC.length + 16 + 12;
function arg(name, def) {
const i = process.argv.indexOf('--' + name);
if (i < 0) return def;
const next = process.argv[i + 1];
return next && !next.startsWith('--') ? next : true;
}
const has = (n) => process.argv.includes('--' + n);
function log(...a) {
console.log('[' + new Date().toTimeString().slice(0, 8) + ']', ...a);
}
const mb = (n) => (n / 1048576).toFixed(1) + ' MB';
function loadEnvFile(p) {
if (!fs.existsSync(p)) return {};
const out = {};
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
if (line.trim().startsWith('#')) continue;
const m = /^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
const cfg = {
...loadEnvFile(path.join(REPO, '.workbuddy-backup', 'openlist-backup.env')),
...loadEnvFile(path.join(os.homedir(), '.openlist-backup.env')),
...Object.fromEntries(
['OPENLIST_URL', 'OPENLIST_USER', 'OPENLIST_PASS', 'BACKUP_PASSPHRASE', 'OPENLIST_DIR']
.filter((k) => process.env[k])
.map((k) => [k, process.env[k]])
),
};
/* ---------- 加密 ---------- */
async function encryptFile(src, dst, passphrase) {
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
const key = crypto.scryptSync(passphrase, salt, 32, SCRYPT);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
fs.writeFileSync(dst, Buffer.concat([MAGIC, salt, iv]));
await pipeline(fs.createReadStream(src), cipher, fs.createWriteStream(dst, { flags: 'a' }));
fs.appendFileSync(dst, cipher.getAuthTag());
}
async function decryptFile(src, passphrase, out) {
const size = fs.statSync(src).size;
if (size < HEAD + 16) throw new Error('文件太小,不是有效备份');
const fd = fs.openSync(src, 'r');
const head = Buffer.alloc(HEAD);
fs.readSync(fd, head, 0, HEAD, 0);
const tag = Buffer.alloc(16);
fs.readSync(fd, tag, 0, 16, size - 16);
fs.closeSync(fd);
if (!head.subarray(0, 8).equals(MAGIC)) throw new Error('magic 不匹配,不是本脚本产生的备份');
const salt = head.subarray(8, 24);
const iv = head.subarray(24, 36);
const key = crypto.scryptSync(passphrase, salt, 32, SCRYPT);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
await pipeline(
fs.createReadStream(src, { start: HEAD, end: size - 17 }),
decipher,
fs.createWriteStream(out)
);
}
/* ---------- WebDAV ---------- */
const BASE = new URL(String(cfg.OPENLIST_URL || 'http://127.0.0.1').replace(/\/+$/, ''));
const AUTH = 'Basic ' + Buffer.from((cfg.OPENLIST_USER || '') + ':' + (cfg.OPENLIST_PASS || '')).toString('base64');
const isHttps = BASE.protocol === 'https:';
const transport = isHttps ? https : http;
const DAVDIR = cfg.OPENLIST_DIR || '/本地/git-backup';
// ★ OpenList 的 WebDAV 端点挂在 /dav 下。少了这个前缀会打到普通 HTTP 路由上,
// 表现是 MKCOL/PUT 全部返回 405 Method Not Allowed(很容易误以为是权限问题)。
const DAV_PREFIX = cfg.OPENLIST_DAV_PREFIX || '/dav';
function davReq(method, urlPath, { file, extraHeaders = {} } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(BASE.href.replace(/\/+$/, '') + urlPath.replace(/ /g, '%20'));
const headers = { Authorization: AUTH, ...extraHeaders };
if (file) headers['Content-Length'] = fs.statSync(file).size;
const req = transport.request(
{ hostname: u.hostname, port: u.port || (isHttps ? 443 : 80), path: u.pathname + u.search, method, headers },
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
}
);
req.on('error', reject);
if (file) {
const rs = fs.createReadStream(file);
rs.on('error', reject);
rs.pipe(req);
} else req.end();
});
}
// prefixEncoded: 已带好并已存在的端点前缀(如 /dav),不参与创建
// relEncoded: 前缀之下、各段已编码的相对目录
async function ensureDir(prefixEncoded, relEncoded) {
let cur = prefixEncoded;
for (const seg of relEncoded.split('/').filter(Boolean)) {
cur += '/' + seg;
const r = await davReq('MKCOL', cur);
if (r.status === 201) { log(' 建目录', decodeURIComponent(cur)); continue; }
// 405 在 WebDAV 里表示「已存在」,但不能无脑相信 —— 真回到普通 HTTP 路由也会给 405。
// 所以补一次 PROPFIND 确认,避免把路径写错(例如漏了 /dav 前缀)当成「目录已存在」而静默放过。
if ([405, 301, 200].includes(r.status)) {
const chk = await davReq('PROPFIND', cur, { extraHeaders: { Depth: '0' } });
if (chk.status >= 400) {
throw new Error(`目录 ${decodeURIComponent(cur)} 既没建成也不存在(MKCOL ${r.status} / PROPFIND ${chk.status})—— 检查 OPENLIST_DIR 与 /dav 前缀`);
}
continue;
}
throw new Error(`建目录失败 ${decodeURIComponent(cur)} → HTTP ${r.status}`);
}
}
function linkNames(xml) {
const out = [];
const re = /<(?:D:|d:)?href>([^<]+)<\/(?:D:|d:)?href>/g;
let m;
while ((m = re.exec(xml))) {
const name = decodeURIComponent(m[1]).replace(/\/+$/, '').split('/').pop();
if (name) out.push(name);
}
return out;
}
function sha256File(p) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256');
const rs = fs.createReadStream(p);
rs.on('data', (c) => h.update(c));
rs.on('end', () => resolve(h.digest('hex')));
rs.on('error', reject);
});
}
const relDirEncoded = DAVDIR.split('/').filter(Boolean).map(encodeURIComponent).join('/');
const davDirPath = DAV_PREFIX + '/' + relDirEncoded;
const RE_BACKUP = /^blog-\d{8}-\d{6}\.bundle(\.enc)?$/;
/* ---------- 子命令:解密 / 列出 ---------- */
const decArg = arg('decrypt', null);
if (decArg) {
const src = path.resolve(String(decArg));
const out = String(arg('out', src.replace(/\.enc$/, '')));
if (!cfg.BACKUP_PASSPHRASE) { console.error('缺少 BACKUP_PASSPHRASE'); process.exit(2); }
await decryptFile(src, cfg.BACKUP_PASSPHRASE, out);
log('解密完成 →', out, mb(fs.statSync(out).size));
log('恢复仓库: git clone "' + out + '" blog-restored');
process.exit(0);
}
if (has('list')) {
await ensureDir(DAV_PREFIX, relDirEncoded);
const ls = await davReq('PROPFIND', davDirPath, { extraHeaders: { Depth: '1' } });
const files = linkNames(ls.body).filter((n) => RE_BACKUP.test(n)).sort().reverse();
log('远端目录', DAVDIR);
for (const f of files) {
const st = await davReq('HEAD', davDirPath + '/' + encodeURIComponent(f));
const when = new Date(st.headers['last-modified'] || Date.now()).toISOString().replace('T', ' ').slice(0, 19);
log(' ' + f + ' ' + mb(Number(st.headers['content-length'] || 0)) + ' ' + when);
}
if (!files.length) log(' (无备份)');
process.exit(0);
}
/* ---------- 主流程 ---------- */
if (!cfg.OPENLIST_URL || !cfg.OPENLIST_USER || !cfg.OPENLIST_PASS) {
console.error('缺少 OpenList 配置(OPENLIST_URL / OPENLIST_USER / OPENLIST_PASS)');
process.exit(2);
}
const ENCRYPT = !has('no-encrypt');
if (ENCRYPT && !cfg.BACKUP_PASSPHRASE) {
console.error('缺少 BACKUP_PASSPHRASE —— 加密备份必须有口令(确实要明文存放请显式加 --no-encrypt)');
process.exit(2);
}
const KEEP = Number(arg('keep', 7)) || 7;
const VERIFY = has('verify');
const DRY = has('dry');
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
const tmpRoot = path.join(REPO, '.workbuddy-backup', 'tmp');
fs.mkdirSync(tmpRoot, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(tmpRoot, 'run-'));
const rawBundle = path.join(tmpDir, `blog-${stamp}.bundle`);
try {
log('仓库:', REPO);
// 0) 先同步远端引用 —— 见文件头「打包前先 git fetch」那段。
// 不 fetch 就会把过期快照当备份,而且失败是静默的(备份照样报"成功")。
const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
if (has('no-fetch')) {
log('0/7 跳过 fetch(--no-fetch)—— 本次打包的是**本地当前 ref** 的快照');
} else {
log('0/7 同步远端引用(git fetch --all --tags --prune)…');
try {
const out = execFileSync('git', ['-C', REPO, 'fetch', '--all', '--tags', '--prune'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: gitEnv, timeout: 180000,
});
const tail = (out || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
log(' 完成' + (tail ? ':' + tail : '(无新内容)'));
} catch (e) {
// 离线不该让备份做不出来 —— 降级为用本地已有 ref 打包,但必须显式告警
const why = String(e.stderr || e.stdout || e.message || '').trim().split('\n').filter(Boolean).slice(-2).join(' ');
log(' ★ fetch 失败,降级为「用本地已有 ref 打包」—— 快照可能不含最新提交');
log(' 原因:', why || '(无输出)');
}
}
log('1/7 打包 bundle(git bundle create --all)…');
let t = Date.now();
execFileSync('git', ['-C', REPO, 'bundle', 'create', rawBundle, '--all'], { stdio: ['ignore', 'inherit', 'inherit'] });
log(` 完成 ${mb(fs.statSync(rawBundle).size)} 用时 ${((Date.now() - t) / 1000).toFixed(1)}s`);
// 把「这份快照停在哪」写进日志 —— 恢复时第一件要确认的就是这一行
try {
const head = execFileSync('git', ['-C', REPO, 'log', '-1', '--format=%h %ci %s', 'origin/main'],
{ encoding: 'utf8', env: gitEnv }).trim();
log(' 快照 origin/main =', head);
} catch { /* 没有 origin/main 就不打这行,不影响备份 */ }
let upload = rawBundle;
let finalName = path.basename(rawBundle);
if (ENCRYPT) {
log('2/7 AES-256-GCM 加密(scrypt 派生密钥)…');
const enc = rawBundle + '.enc';
t = Date.now();
await encryptFile(rawBundle, enc, cfg.BACKUP_PASSPHRASE);
fs.unlinkSync(rawBundle);
upload = enc;
finalName = path.basename(enc);
log(` 完成 ${mb(fs.statSync(upload).size)} 用时 ${((Date.now() - t) / 1000).toFixed(1)}s`);
} else {
log('2/7 跳过加密(--no-encrypt)—— 请确认历史里已无敏感文件');
}
const size = fs.statSync(upload).size;
const sha = await sha256File(upload);
log(` 本地 sha256 = ${sha}`);
if (DRY) {
log('--dry:不上传。文件留在', upload);
process.exit(0);
}
log('3/7 准备远端目录', DAVDIR);
await ensureDir(DAV_PREFIX, relDirEncoded);
log('4/7 上传中…');
t = Date.now();
const put = await davReq('PUT', davDirPath + '/' + encodeURIComponent(finalName), {
file: upload, extraHeaders: { 'Content-Type': 'application/octet-stream' },
});
const secs = (Date.now() - t) / 1000;
if (![200, 201, 204].includes(put.status)) throw new Error('上传失败 HTTP ' + put.status);
log(` 完成 ${mb(size)} / ${secs.toFixed(1)}s → ${(size / 1048576 / secs).toFixed(1)} MB/s`);
log('5/7 校验远端');
const stat = await davReq('HEAD', davDirPath + '/' + encodeURIComponent(finalName));
const remoteLen = Number(stat.headers['content-length'] || 0);
if (remoteLen !== size) log(` ★ 远端字节数不一致(本地 ${size} / 远端 ${remoteLen})`);
else log(` 远端字节数一致 (${mb(remoteLen)})`);
if (VERIFY) {
log(' 下载回来比对 sha256…');
const back = path.join(tmpDir, 'readback.bin');
await new Promise((res, rej) => {
const req = transport.request(
{ hostname: BASE.hostname, port: BASE.port || (isHttps ? 443 : 80),
path: davDirPath + '/' + encodeURIComponent(finalName), method: 'GET', headers: { Authorization: AUTH } },
(r) => {
if (r.statusCode !== 200) return rej(new Error('GET ' + r.statusCode));
const ws = fs.createWriteStream(back);
r.pipe(ws); ws.on('finish', res); ws.on('error', rej);
}
);
req.on('error', rej); req.end();
});
const sha2 = await sha256File(back);
log(' ' + (sha2 === sha ? '✓ 读回 sha256 一致 —— 数据完整' : '★ 读回 sha256 不一致 —— 备份已损坏'));
fs.unlinkSync(back);
}
log(`6/7 清理旧份(保留最近 ${KEEP} 份)`);
const ls = await davReq('PROPFIND', davDirPath, { extraHeaders: { Depth: '1' } });
const files = linkNames(ls.body).filter((n) => RE_BACKUP.test(n)).sort().reverse();
log(' 远端现有:', files.join(', ') || '(无)');
for (const old of files.slice(KEEP)) {
const r = await davReq('DELETE', davDirPath + '/' + encodeURIComponent(old));
log(` 删除 ${old} → HTTP ${r.status}`);
}
log('完成。备份文件:', DAVDIR + '/' + finalName);
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch (e) {
console.error('\n失败:', e.message);
console.error('(临时文件保留在 ' + tmpDir + ' 便于排查)');
process.exit(1);
}