2026-10-06 21:03:29 +08:00
|
|
|
|
#!/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` 完成。
|
|
|
|
|
|
*
|
2026-10-06 21:53:49 +08:00
|
|
|
|
* ★ 打包前先 `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 打包」并显著告警。
|
|
|
|
|
|
*
|
2026-10-06 21:03:29 +08:00
|
|
|
|
* 用法:
|
2026-10-06 21:53:49 +08:00
|
|
|
|
* node scripts/backup-bundle.mjs # fetch + 加密 + 上传 + 比对字节数
|
2026-10-06 21:03:29 +08:00
|
|
|
|
* node scripts/backup-bundle.mjs --verify # 额外下载回来比对 sha256(慢,但端到端最可靠)
|
|
|
|
|
|
* node scripts/backup-bundle.mjs --dry # 只打包加密,不上传
|
2026-10-06 21:27:28 +08:00
|
|
|
|
* node scripts/backup-bundle.mjs --keep 7 # 远端保留最近 7 份(默认)
|
2026-10-06 21:53:49 +08:00
|
|
|
|
* node scripts/backup-bundle.mjs --no-fetch # 跳过 fetch(离线/调试用)
|
2026-10-06 21:03:29 +08:00
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 21:27:28 +08:00
|
|
|
|
const KEEP = Number(arg('keep', 7)) || 7;
|
2026-10-06 21:03:29 +08:00
|
|
|
|
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);
|
2026-10-06 21:53:49 +08:00
|
|
|
|
|
|
|
|
|
|
// 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)…');
|
2026-10-06 21:03:29 +08:00
|
|
|
|
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`);
|
2026-10-06 21:53:49 +08:00
|
|
|
|
// 把「这份快照停在哪」写进日志 —— 恢复时第一件要确认的就是这一行
|
|
|
|
|
|
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 就不打这行,不影响备份 */ }
|
2026-10-06 21:03:29 +08:00
|
|
|
|
|
|
|
|
|
|
let upload = rawBundle;
|
|
|
|
|
|
let finalName = path.basename(rawBundle);
|
|
|
|
|
|
|
|
|
|
|
|
if (ENCRYPT) {
|
2026-10-06 21:53:49 +08:00
|
|
|
|
log('2/7 AES-256-GCM 加密(scrypt 派生密钥)…');
|
2026-10-06 21:03:29 +08:00
|
|
|
|
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 {
|
2026-10-06 21:53:49 +08:00
|
|
|
|
log('2/7 跳过加密(--no-encrypt)—— 请确认历史里已无敏感文件');
|
2026-10-06 21:03:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const size = fs.statSync(upload).size;
|
|
|
|
|
|
const sha = await sha256File(upload);
|
|
|
|
|
|
log(` 本地 sha256 = ${sha}`);
|
|
|
|
|
|
|
|
|
|
|
|
if (DRY) {
|
|
|
|
|
|
log('--dry:不上传。文件留在', upload);
|
|
|
|
|
|
process.exit(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 21:53:49 +08:00
|
|
|
|
log('3/7 准备远端目录', DAVDIR);
|
2026-10-06 21:03:29 +08:00
|
|
|
|
await ensureDir(DAV_PREFIX, relDirEncoded);
|
|
|
|
|
|
|
2026-10-06 21:53:49 +08:00
|
|
|
|
log('4/7 上传中…');
|
2026-10-06 21:03:29 +08:00
|
|
|
|
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`);
|
|
|
|
|
|
|
2026-10-06 21:53:49 +08:00
|
|
|
|
log('5/7 校验远端');
|
2026-10-06 21:03:29 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 21:53:49 +08:00
|
|
|
|
log(`6/7 清理旧份(保留最近 ${KEEP} 份)`);
|
2026-10-06 21:03:29 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|