Files
rootandClaude Sonnet 5.5 2bcb625c11 Links in chrome.storage.local statt storage.sync (v1.2.0)
Bestehende Daten werden einmalig aus storage.sync übernommen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 18:23:32 +00:00

205 lines
8.4 KiB
JavaScript
Raw Permalink 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-/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 };
})();