/* ==================== 简单写作 · 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) { /* 忽略,等待用户手动登录 */ } })(); } })();