Bestehende Daten werden einmalig aus storage.sync übernommen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
205 lines
8.4 KiB
JavaScript
205 lines
8.4 KiB
JavaScript
// WebDAV-/Nextcloud-Sync. Gemeinsam genutzt von Popup und Einstellungsseite.
|
||
// Lokale Daten: chrome.storage.local {links, deleted, modified}
|
||
// Zugangsdaten: chrome.storage.local {webdav: {url, user, pass}} (nie über Google synchronisiert)
|
||
const LabSync = (() => {
|
||
const FILE = "lablinks.json";
|
||
const TOMB_TTL = 60 * 24 * 3600 * 1000;
|
||
|
||
const MAX_LINKS = 500;
|
||
const MAX_TOMBS = 1000;
|
||
const MAX_BODY = 1_000_000;
|
||
const DAY = 24 * 3600 * 1000;
|
||
|
||
// Nur http(s) – verhindert javascript:, data:, file:, chrome: usw.
|
||
function safeUrl(raw) {
|
||
try {
|
||
const u = new URL(String(raw));
|
||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||
return u.href.length <= 2048 ? u.href : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
// Daten aus Storage, Remote-Datei oder Import sind nicht vertrauenswürdig.
|
||
function cleanLink(x) {
|
||
if (!x || typeof x !== "object") return null;
|
||
const id = String(x.id ?? "");
|
||
const url = safeUrl(x.url);
|
||
const name = String(x.name ?? "").trim().slice(0, 80);
|
||
if (!/^[\w-]{1,64}$/.test(id) || !url || !name) return null;
|
||
const icon = typeof x.icon === "string" ? [...x.icon].slice(0, 2).join("") : "";
|
||
const updated = Math.min(Math.max(Number(x.updated) || 0, 0), Date.now() + DAY);
|
||
return { id, name, url, icon, updated };
|
||
}
|
||
function cleanTomb(x) {
|
||
if (!x || typeof x !== "object" || !/^[\w-]{1,64}$/.test(String(x.id ?? ""))) return null;
|
||
return { id: String(x.id), updated: Math.min(Math.max(Number(x.updated) || 0, 0), Date.now() + DAY) };
|
||
}
|
||
function cleanState(d) {
|
||
const arr = (v) => (Array.isArray(v) ? v : []);
|
||
return {
|
||
links: arr(d.links).slice(0, MAX_LINKS).map(cleanLink).filter(Boolean),
|
||
deleted: arr(d.deleted).slice(0, MAX_TOMBS).map(cleanTomb).filter(Boolean),
|
||
modified: Math.min(Math.max(Number(d.modified) || 0, 0), Date.now() + DAY),
|
||
};
|
||
}
|
||
|
||
// Prüft die Server-URL. Basic-Auth über http:// nur im lokalen Netz erlauben.
|
||
function isPrivateHost(h) {
|
||
if (h === "localhost" || h === "[::1]" || !h.includes(".")) return true;
|
||
if (/\.(local|lan|home\.arpa|internal|localdomain)$/.test(h)) return true;
|
||
const m = h.match(/^(\d+)\.(\d+)\.\d+\.\d+$/);
|
||
if (!m) return false;
|
||
const [a, b] = [+m[1], +m[2]];
|
||
return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254);
|
||
}
|
||
function checkServerUrl(raw) {
|
||
let u;
|
||
try { u = new URL(String(raw).trim()); } catch { throw new Error("Ungültige URL"); }
|
||
if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error("Nur http(s)-URLs erlaubt");
|
||
if (u.username || u.password) throw new Error("Zugangsdaten bitte in die Felder, nicht in die URL");
|
||
const insecure = u.protocol === "http:";
|
||
if (insecure && !isPrivateHost(u.hostname))
|
||
throw new Error("Unverschlüsseltes HTTP ist nur im lokalen Netz erlaubt – bitte HTTPS verwenden");
|
||
return { origin: u.origin, insecure };
|
||
}
|
||
|
||
const getConfig = async () => (await chrome.storage.local.get("webdav")).webdav || null;
|
||
const setConfig = (cfg) =>
|
||
cfg ? chrome.storage.local.set({ webdav: cfg }) : chrome.storage.local.remove("webdav");
|
||
|
||
const KEYS = ["links", "deleted", "modified"];
|
||
|
||
// Bis v1.1.1 lagen die Daten in chrome.storage.sync (Limit ~8 KB) -> einmalig übernehmen.
|
||
async function migrateFromSyncStorage() {
|
||
const old = await chrome.storage.sync.get(KEYS);
|
||
if (old.links) await chrome.storage.local.set(old);
|
||
await chrome.storage.sync.remove(KEYS);
|
||
}
|
||
|
||
async function readLocal() {
|
||
let d = await chrome.storage.local.get(KEYS);
|
||
if (!d.links) {
|
||
await migrateFromSyncStorage();
|
||
d = await chrome.storage.local.get(KEYS);
|
||
}
|
||
return cleanState(d);
|
||
}
|
||
const writeLocal = (s) =>
|
||
chrome.storage.local.set({ links: s.links, deleted: s.deleted, modified: s.modified });
|
||
|
||
const baseUrl = (cfg) => cfg.url.trim().replace(/\/+$/, "");
|
||
const fileUrl = (cfg) => baseUrl(cfg) + "/" + FILE;
|
||
|
||
function authHeader(cfg) {
|
||
const bytes = new TextEncoder().encode(`${cfg.user}:${cfg.pass}`);
|
||
return "Basic " + btoa(String.fromCharCode(...bytes));
|
||
}
|
||
|
||
async function dav(cfg, method, url, headers = {}, body) {
|
||
let res;
|
||
try {
|
||
res = await fetch(url, {
|
||
method,
|
||
headers: { Authorization: authHeader(cfg), ...headers },
|
||
body,
|
||
credentials: "omit",
|
||
cache: "no-store",
|
||
});
|
||
} catch {
|
||
throw new Error("Server nicht erreichbar (URL, Zertifikat oder Berechtigung prüfen)");
|
||
}
|
||
if (res.status === 401 || res.status === 403)
|
||
throw new Error("Login abgelehnt (Benutzer / App-Passwort prüfen)");
|
||
return res;
|
||
}
|
||
|
||
async function fetchRemote(cfg) {
|
||
const res = await dav(cfg, "GET", fileUrl(cfg));
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) throw new Error(`Server antwortet mit HTTP ${res.status}`);
|
||
const text = await res.text();
|
||
if (text.length > MAX_BODY) throw new Error("Remote-Datei ist unerwartet groß");
|
||
let data;
|
||
try { data = JSON.parse(text); } catch { throw new Error("Remote-Datei ist kein gültiges JSON"); }
|
||
if (!data || typeof data !== "object" || !Array.isArray(data.links))
|
||
throw new Error("Remote-Datei hat ein unbekanntes Format");
|
||
return { data: cleanState(data), etag: res.headers.get("ETag") };
|
||
}
|
||
|
||
// -> "ok" | "conflict"
|
||
async function putRemote(cfg, data, etag) {
|
||
const headers = { "Content-Type": "application/json" };
|
||
if (etag) headers["If-Match"] = etag; else headers["If-None-Match"] = "*";
|
||
const body = JSON.stringify({ version: 1, ...data }, null, 2);
|
||
|
||
let res = await dav(cfg, "PUT", fileUrl(cfg), headers, body);
|
||
if (res.status === 404 || res.status === 409) {
|
||
// Ordner existiert noch nicht -> anlegen und erneut versuchen
|
||
const mk = await dav(cfg, "MKCOL", baseUrl(cfg));
|
||
if (mk.ok || mk.status === 405) res = await dav(cfg, "PUT", fileUrl(cfg), headers, body);
|
||
}
|
||
if (res.status === 412) return "conflict";
|
||
if (!res.ok) throw new Error(`Schreiben fehlgeschlagen (HTTP ${res.status})`);
|
||
return "ok";
|
||
}
|
||
|
||
// Pro Eintrag gewinnt der neuere `updated`-Zeitstempel; Löschungen sind Tombstones.
|
||
function merge(a, b) {
|
||
const tombs = new Map();
|
||
for (const t of [...a.deleted, ...b.deleted]) {
|
||
const cur = tombs.get(t.id);
|
||
if (!cur || cur.updated < t.updated) tombs.set(t.id, t);
|
||
}
|
||
const byId = new Map();
|
||
for (const l of [...a.links, ...b.links]) {
|
||
const cur = byId.get(l.id);
|
||
if (!cur || (l.updated || 0) > (cur.updated || 0)) byId.set(l.id, l);
|
||
}
|
||
for (const [id, t] of tombs) {
|
||
const l = byId.get(id);
|
||
if (l && (l.updated || 0) > t.updated) tombs.delete(id); // nach dem Löschen neu bearbeitet
|
||
else byId.delete(id);
|
||
}
|
||
// Reihenfolge der zuletzt geänderten Seite übernehmen, Rest anhängen
|
||
const [first, second] = a.modified >= b.modified ? [a, b] : [b, a];
|
||
const order = [...first.links, ...second.links].map((l) => l.id);
|
||
const links = [...new Set(order)].filter((id) => byId.has(id)).map((id) => byId.get(id));
|
||
const now = Date.now();
|
||
const deleted = [...tombs.values()].filter((t) => now - t.updated < TOMB_TTL);
|
||
return { links, deleted, modified: Math.max(a.modified, b.modified) };
|
||
}
|
||
|
||
const sig = (s) => JSON.stringify([s.links, s.deleted]);
|
||
|
||
let running = null;
|
||
function sync() {
|
||
return (running ??= doSync().finally(() => (running = null)));
|
||
}
|
||
|
||
async function doSync() {
|
||
const cfg = await getConfig();
|
||
if (!cfg || !cfg.url) return { skipped: true };
|
||
checkServerUrl(cfg.url);
|
||
|
||
for (let attempt = 0; attempt < 4; attempt++) {
|
||
const local = await readLocal();
|
||
const remote = await fetchRemote(cfg);
|
||
const merged = merge(local, remote ? remote.data : { links: [], deleted: [], modified: 0 });
|
||
|
||
// Hat sich lokal währenddessen etwas geändert? Dann nochmal von vorn.
|
||
if ((await readLocal()).modified !== local.modified) continue;
|
||
|
||
const localChanged = sig(merged) !== sig(local);
|
||
if (localChanged) await writeLocal(merged);
|
||
|
||
if (!remote || sig(merged) !== sig(remote.data)) {
|
||
if ((await putRemote(cfg, merged, remote?.etag)) === "conflict") continue;
|
||
}
|
||
return { links: merged.links.length, pulled: localChanged };
|
||
}
|
||
throw new Error("Sync-Konflikt, bitte erneut versuchen");
|
||
}
|
||
|
||
return { getConfig, setConfig, readLocal, writeLocal, sync, safeUrl, cleanLink, checkServerUrl };
|
||
})();
|