Files
SimpleWriting/src/sync.js
T
2026-07-10 17:09:39 +08:00

372 lines
15 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.
/* ==================== 简单写作 · WebDAV 同步(前端) ==================== */
/* 内容在浏览器端用 AES-GCM 端到端加密,经本机后端 /api/webdav/ 代理转发到 WebDAV(代理不接触明文)。
* 浏览器直连第三方 WebDAV 会被 CORS 拦截,因此统一经同源代理。
*/
(function () {
"use strict";
const CC = () => window.JianDan;
const SYNC_KEY = "jiandan.syncCfg";
let cfg = loadCfg(); // { webdav:{base,path,user,pass,crypto}, lastSync, auto }
if (cfg.auto === undefined) cfg.auto = true; // 默认开启自动上传
let state = { token: null, key: null, loggedIn: false, username: "", wd: null, lastEtag: null };
const $ = (id) => document.getElementById(id);
const panel = $("syncPanel");
const syncBtn = $("syncBtn");
const badge = $("syncBadge");
// 上传成功后在同步图标右下角显示绿色✅;同步中/失败/登出时隐藏
function showBadge(on) {
if (badge) badge.hidden = !on;
}
// 旧版配置若残留「自建服务器」模式,统一迁移为 WebDAV
if (cfg.mode === "server") { delete cfg.mode; saveCfg(); }
// ---------- 配置持久化 ----------
function loadCfg() {
try { return JSON.parse(localStorage.getItem(SYNC_KEY)) || {}; }
catch { return {}; }
}
function saveCfg() { localStorage.setItem(SYNC_KEY, JSON.stringify(cfg)); }
// ---------- 加解密(Web Crypto,端到端) ----------
function b64(buf) {
const bytes = new Uint8Array(buf);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function b64ToBytes(b) {
const s = atob(b);
const a = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
return a;
}
async function deriveKey(password, username) {
const enc = new TextEncoder();
const salt = enc.encode("jiandan-sync-v1::" + username);
const base = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
async function enc(key, text) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text || ""));
return b64(iv) + ":" + b64(ct);
}
async function dec(key, blob) {
if (!blob) return "";
const [ivb, ctb] = String(blob).split(":");
if (!ivb || !ctb) return "";
const iv = b64ToBytes(ivb);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64ToBytes(ctb));
return new TextDecoder().decode(pt);
}
// ---------- 工具 ----------
async function encryptDoc(key, d) {
return { id: d.id, title: await enc(key, d.title), content: await enc(key, d.content), updated: d.updated };
}
async function decryptDoc(key, e) {
try {
return { id: e.id, title: await dec(key, e.title), content: await dec(key, e.content), updated: e.updated };
} catch (e) { return null; }
}
function mergeIntoLocal(remoteDocs) {
const apiObj = CC();
const map = {};
for (const d of apiObj.getDocs()) map[d.id] = d;
for (const r of remoteDocs) {
const ex = map[r.id];
if (!ex || (r.updated || 0) > (ex.updated || 0)) map[r.id] = r;
}
apiObj.setDocs(Object.values(map));
}
// ---------- 路径工具(备份存档用) ----------
const pad2 = (n) => String(n).padStart(2, "0");
function dirOf(p) { const i = String(p || "").lastIndexOf("/"); return i >= 0 ? p.slice(0, i) : ""; }
function joinPath(dir, name) { return dir ? dir.replace(/\/$/, "") + "/" + name : name; }
function backupName() {
const d = new Date();
return `jiandan-backup-${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}.json`;
}
function fmtBackupLabel(name) {
const m = name.match(/jiandan-backup-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.json/i);
return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : name;
}
// ---------- WebDAV 代理 API(同源,绕过 CORS ----------
async function wdApi(ep, o) {
try {
const res = await fetch("/api/webdav/" + ep, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(o),
});
return await res.json();
} catch (e) { return { status: 0, error: e.message }; }
}
// ---------- 同步核心:WebDAV ----------
let wdSyncing = false;
let wdDirty = false;
let pollTimer = null;
async function wdSyncNow() {
if (!state.loggedIn) return;
if (wdSyncing) return;
wdSyncing = true;
showBadge(false);
setStatus("同步中…");
try {
const wd = state.wd;
const key = state.key;
// 1. 检查远程是否有变化
const pr = await wdApi("propfind", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass });
if (pr.status === 401 || pr.status === 403) { setStatus("WebDAV 认证失败,请检查账号密码"); stopPoll(); return; }
if (pr.status !== 200 && pr.status !== 207 && pr.status !== 404) {
throw new Error("WebDAV " + (pr.error || pr.status));
}
let remoteDocs = [];
if (pr.status === 200 && pr.etag && pr.etag === state.lastEtag && !wdDirty) {
// 远程无变化且本地无改动
setStatus("已是最新 · " + CC().fmtDate(cfg.lastSync || Date.now()));
return;
}
if (pr.status === 200) {
const gr = await wdApi("get", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass });
if (gr.status === 200 && gr.content) {
try {
const parsed = JSON.parse(gr.content);
for (const e of (parsed.docs || [])) {
const d = await decryptDoc(key, e);
if (d) remoteDocs.push(d);
}
} catch (e) { /* 忽略坏数据 */ }
}
}
// 2. 合并远程到本地
mergeIntoLocal(remoteDocs);
// 3. 把本地最新整文件推回 WebDAV
const apiObj = CC();
const toPush = [];
for (const d of apiObj.getDocs()) toPush.push(await encryptDoc(key, d));
const payload = JSON.stringify({ docs: toPush, v: 1 });
const ur = await wdApi("put", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass, content: payload });
if (ur.status !== 200 && ur.status !== 201 && ur.status !== 204 && ur.status !== 207) {
throw new Error("上传失败 " + (ur.error || ur.status));
}
wdDirty = false;
state.lastEtag = ur.etag || pr.etag || null;
apiObj.persist();
apiObj.renderList();
apiObj.renderEditor();
cfg.lastSync = Date.now();
saveCfg();
showBadge(true);
setStatus("已同步 · " + apiObj.fmtDate(cfg.lastSync));
} catch (e) {
showBadge(false);
setStatus("同步失败:" + e.message);
} finally {
wdSyncing = false;
schedulePoll();
}
}
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
if (!state.loggedIn || cfg.auto === false) return;
pollTimer = setTimeout(() => wdSyncNow(), 8000);
}
function stopPoll() { if (pollTimer) clearTimeout(pollTimer); }
// ---------- 手动备份存档(独立快照,不覆盖主同步文件) ----------
async function manualBackup() {
if (!state.loggedIn) { setStatus("请先登录 WebDAV"); return; }
const wd = state.wd, key = state.key;
setStatus("生成存档中…");
try {
const apiObj = CC();
const toPush = [];
for (const d of apiObj.getDocs()) toPush.push(await encryptDoc(key, d));
const payload = JSON.stringify({ docs: toPush, v: 1, backupAt: Date.now() });
const name = backupName();
const p = joinPath(dirOf(wd.path), name);
const r = await wdApi("put", { base: wd.base, path: p, user: wd.user, pass: wd.pass, content: payload });
if (r.status !== 200 && r.status !== 201 && r.status !== 204 && r.status !== 207) {
throw new Error("存档失败 " + (r.error || r.status));
}
setStatus("已生成存档 · " + fmtBackupLabel(name));
loadBackupList();
} catch (e) { setStatus("存档失败:" + e.message); }
}
async function loadBackupList() {
if (!state.loggedIn) return;
const wd = state.wd;
const sel = $("backupSelect");
if (!sel) return;
try {
const r = await wdApi("list", { base: wd.base, path: dirOf(wd.path), user: wd.user, pass: wd.pass });
const names = (r.hrefs || [])
.map((h) => h.split("/").filter(Boolean).pop() || "")
.filter((n) => /^jiandan-backup-.*\.json$/i.test(n));
// 去重 + 按时间倒序(文件名含时间戳,字典序即时间序)
const uniq = Array.from(new Set(names)).sort().reverse();
sel.innerHTML = "";
if (!uniq.length) {
const o = document.createElement("option");
o.value = ""; o.textContent = "(暂无存档)";
sel.appendChild(o);
return;
}
for (const n of uniq) {
const o = document.createElement("option");
o.value = n; o.textContent = fmtBackupLabel(n);
sel.appendChild(o);
}
} catch (e) { /* 忽略列表加载失败 */ }
}
async function restoreBackup() {
if (!state.loggedIn) { setStatus("请先登录 WebDAV"); return; }
const sel = $("backupSelect");
const name = sel ? sel.value : "";
if (!name) { setStatus("请先选择一个存档"); return; }
if (!window.confirm(`确定用存档「${fmtBackupLabel(name)}」恢复吗?\n将与当前内容合并(同一篇以更新时间较新者为准),不会删除本地文章。`)) return;
const wd = state.wd, key = state.key;
setStatus("恢复中…");
try {
const p = joinPath(dirOf(wd.path), name);
const gr = await wdApi("get", { base: wd.base, path: p, user: wd.user, pass: wd.pass });
if (gr.status !== 200 || !gr.content) throw new Error("读取存档失败 " + (gr.error || gr.status));
const parsed = JSON.parse(gr.content);
const remoteDocs = [];
for (const e of (parsed.docs || [])) { const d = await decryptDoc(key, e); if (d) remoteDocs.push(d); }
if (!remoteDocs.length) throw new Error("存档为空或解密失败(检查加密密码)");
mergeIntoLocal(remoteDocs);
const apiObj = CC();
apiObj.persist(); apiObj.renderList(); apiObj.renderEditor();
setStatus("已从存档恢复 · " + fmtBackupLabel(name));
wdDirty = true;
wdSyncNow(); // 把恢复合并后的结果推回主同步文件
} catch (e) { setStatus("恢复失败:" + e.message); }
}
// ---------- 统一入口 ----------
function syncNow() { return wdSyncNow(); }
let wdTimer = null;
function scheduleSync() {
if (!state.loggedIn) return;
wdDirty = true;
if (cfg.auto === false) return;
if (wdTimer) clearTimeout(wdTimer);
wdTimer = setTimeout(() => wdSyncNow(), 1200);
}
window.JianDanSync = { scheduleSync };
// ---------- 登录:WebDAV ----------
async function doWdLogin() {
const base = $("wdServer").value.trim();
const path = $("wdPath").value.trim() || "jiandan-sync.json";
const user = $("wdUser").value.trim();
const pass = $("wdPass").value;
const cryptoPass = $("wdCrypto").value;
if (!base || !user || !pass || !cryptoPass) { setStatus("请填写完整(WebDAV 账号/密码 + 加密密码)"); return; }
if (!crypto.subtle) { setStatus("需 HTTPS 才能同步"); return; }
const autoOn = $("syncAutoLogin") ? $("syncAutoLogin").checked : true;
setStatus("连接 WebDAV 中…");
try {
const pr = await wdApi("propfind", { base, path, user, pass });
if (pr.status !== 200 && pr.status !== 207 && pr.status !== 404) {
throw new Error("连接失败(" + (pr.error || pr.status) + "),检查地址/账号");
}
const key = await deriveKey(cryptoPass, user);
state = { token: null, key, loggedIn: true, username: user, wd: { base, path, user, pass }, lastEtag: null };
cfg.webdav = { base, path, user, pass, crypto: cryptoPass };
cfg.auto = autoOn !== false; saveCfg();
showReady();
wdSyncNow();
} catch (e) { setStatus("登录失败:" + e.message); }
}
function doLogout() {
stopPoll();
showBadge(false);
state = { token: null, key: null, loggedIn: false, username: "", wd: null, lastEtag: null };
delete cfg.webdav;
saveCfg();
showLogin(); setStatus("");
}
// ---------- UI ----------
function showReady() {
$("syncLogin").hidden = true;
$("syncReady").hidden = false;
$("syncUserLabel").textContent = state.username + "WebDAV";
$("syncAuto").checked = cfg.auto !== false;
$("syncStatus").textContent = cfg.lastSync ? ("上次同步 " + CC().fmtDate(cfg.lastSync)) : "";
loadBackupList();
}
function showLogin() {
$("syncLogin").hidden = false;
$("syncReady").hidden = true;
const autoEl = $("syncAutoLogin");
if (autoEl) autoEl.checked = cfg.auto !== false;
if (cfg.webdav) {
$("wdServer").value = cfg.webdav.base || "";
$("wdPath").value = cfg.webdav.path || "jiandan-sync.json";
$("wdUser").value = cfg.webdav.user || "";
$("wdPass").value = cfg.webdav.pass || "";
$("wdCrypto").value = cfg.webdav.crypto || "";
}
}
function setStatus(t) { const el = $("syncStatus"); if (el) el.textContent = t; }
function openPanel() {
if (!state.loggedIn) showLogin(); else showReady();
panel.hidden = false;
}
function closePanel() { panel.hidden = true; }
// ---------- 事件 ----------
syncBtn.addEventListener("click", (e) => { e.stopPropagation(); panel.hidden = !panel.hidden; if (!panel.hidden) openPanel(); });
$("syncClose").addEventListener("click", closePanel);
$("syncLoginBtn").addEventListener("click", doWdLogin);
$("syncNow").addEventListener("click", syncNow);
$("syncLogout").addEventListener("click", doLogout);
$("backupNow").addEventListener("click", manualBackup);
$("backupRefresh").addEventListener("click", loadBackupList);
$("backupRestore").addEventListener("click", restoreBackup);
$("syncAuto").addEventListener("change", (e) => { cfg.auto = e.target.checked; saveCfg(); if (e.target.checked) schedulePoll(); });
document.addEventListener("click", (e) => {
if (!panel.hidden && !panel.contains(e.target) && e.target.id !== "syncBtn") closePanel();
});
["wdServer", "wdPath", "wdUser", "wdPass", "wdCrypto"].forEach((id) =>
$(id).addEventListener("keydown", (e) => { if (e.key === "Enter") doWdLogin(); })
);
// ---------- WebDAV 自动恢复(刷新后免登录继续同步) ----------
if (cfg.webdav && cfg.webdav.base && cfg.webdav.crypto) {
(async () => {
try {
const w = cfg.webdav;
const key = await deriveKey(w.crypto, w.user);
const pr = await wdApi("propfind", { base: w.base, path: w.path, user: w.user, pass: w.pass });
state = { token: null, key, loggedIn: true, username: w.user, wd: { base: w.base, path: w.path, user: w.user, pass: w.pass }, lastEtag: null };
showReady();
wdSyncNow();
} catch (e) { /* 忽略,等待用户手动登录 */ }
})();
}
})();