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