Files
rootandClaude Sonnet 5.5 a04aaae859 Sicherheits-Härtung (v1.1.1) und aktuelle README
- nur http(s)-Links, Validierung/Limits für Remote-, Storage- und Importdaten
- HTTP-Sync nur im lokalen Netz, keine Zugangsdaten in der URL
- Berechtigung bei Serverwechsel entziehen, explizite CSP, UUID-IDs

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

195 lines
5.8 KiB
JavaScript

const $ = (id) => document.getElementById(id);
let links = [];
let deleted = [];
let editing = false;
let editId = null;
let syncOn = false;
let syncState = ""; // "", "busy", "ok", "err"
const uid = () => crypto.randomUUID();
function normalizeUrl(raw) {
raw = raw.trim();
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = "http://" + raw;
return LabSync.safeUrl(raw);
}
async function load() {
const s = await LabSync.readLocal();
links = s.links;
deleted = s.deleted;
// Einträge aus Version 1.0 haben noch keinen Zeitstempel
if (links.some((l) => !l.updated)) {
links = links.map((l) => ({ ...l, updated: l.updated || Date.now() }));
await save(false);
}
render();
syncOn = !!(await LabSync.getConfig())?.url;
render();
if (syncOn) runSync();
}
async function save(sync = true) {
await LabSync.writeLocal({ links, deleted, modified: Date.now() });
if (sync && syncOn) runSync();
}
async function runSync() {
setSync("busy");
try {
await LabSync.sync();
const s = await LabSync.readLocal();
links = s.links;
deleted = s.deleted;
setSync("ok");
} catch (e) {
setSync("err", e.message);
}
render();
}
function setSync(state, msg = "") {
syncState = state;
$("syncBtn").title = msg || "Mit WebDAV synchronisieren";
}
function render() {
const q = $("search").value.trim().toLowerCase();
const list = $("list");
list.textContent = "";
const shown = links.filter((l) => !q || (l.name + " " + l.url).toLowerCase().includes(q));
for (const l of shown) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = l.url;
a.target = "_blank";
a.rel = "noopener";
a.innerHTML = '<span class="ico"></span><span class="txt"><span class="name"></span><span class="url"></span></span>';
a.querySelector(".ico").textContent = l.icon || "▣";
a.querySelector(".name").textContent = l.name;
a.querySelector(".url").textContent = l.url.replace(/^https?:\/\//, "").replace(/\/$/, "");
if (editing) a.addEventListener("click", (e) => e.preventDefault());
li.append(a);
const eb = document.createElement("div");
eb.className = "edit-btns";
eb.append(
btn("[✎]", () => openForm(l)),
btn("[↑]", () => move(l.id, -1)),
btn("[↓]", () => move(l.id, 1)),
Object.assign(btn("[✕]", () => remove(l.id)), { className: "del" })
);
li.append(eb);
list.append(li);
}
$("empty").hidden = links.length > 0 || !!editId;
$("count").textContent = links.length ? `${links.length} Einträge` : "";
document.body.classList.toggle("editing", editing);
for (const id of ["add", "export", "import"]) $(id).hidden = !editing;
$("editToggle").textContent = editing ? "[FERTIG]" : "[EDIT]";
$("syncBtn").hidden = !syncOn;
$("syncBtn").className = syncState;
}
function btn(label, fn) {
const b = document.createElement("button");
b.textContent = label;
b.addEventListener("click", fn);
return b;
}
async function move(id, dir) {
const i = links.findIndex((l) => l.id === id);
const j = i + dir;
if (j < 0 || j >= links.length) return;
[links[i], links[j]] = [links[j], links[i]];
await save();
render();
}
async function remove(id) {
links = links.filter((l) => l.id !== id);
deleted = [...deleted.filter((t) => t.id !== id), { id, updated: Date.now() }];
await save();
render();
}
function openForm(link) {
editId = link ? link.id : "new";
$("fIcon").value = link?.icon || "";
$("fName").value = link?.name || "";
$("fUrl").value = link?.url || "";
$("form").hidden = false;
$("fName").focus();
}
function closeForm() {
editId = null;
$("form").hidden = true;
render();
}
$("form").addEventListener("submit", async (e) => {
e.preventDefault();
const url = normalizeUrl($("fUrl").value);
if (!url) {
$("fUrl").setCustomValidity("Nur gültige http(s)-URLs");
$("fUrl").reportValidity();
return;
}
const entry = { name: $("fName").value.trim(), url, icon: $("fIcon").value.trim(), updated: Date.now() };
if (editId === "new") links.push({ id: uid(), ...entry });
else Object.assign(links.find((l) => l.id === editId), entry);
await save();
closeForm();
});
$("fUrl").addEventListener("input", () => $("fUrl").setCustomValidity(""));
$("cancel").addEventListener("click", closeForm);
$("add").addEventListener("click", () => openForm());
$("search").addEventListener("input", render);
$("editToggle").addEventListener("click", () => {
editing = !editing;
if (!editing) { editId = null; $("form").hidden = true; }
render();
});
$("syncBtn").addEventListener("click", runSync);
$("settings").addEventListener("click", () => chrome.runtime.openOptionsPage());
// Enter im Suchfeld öffnet den ersten Treffer
$("search").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !editing) $("list").querySelector("a")?.click();
});
$("export").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(links, null, 2)], { type: "application/json" });
const a = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: "lablinks.json" });
a.click();
URL.revokeObjectURL(a.href);
});
$("import").addEventListener("click", () => $("file").click());
$("file").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const arr = JSON.parse(await file.text());
const now = Date.now();
if (!Array.isArray(arr)) throw new Error("kein Array");
const imported = arr
.slice(0, 500)
.map((x) => LabSync.cleanLink({ ...x, id: uid(), url: x && normalizeUrl(String(x.url ?? "")), updated: now }))
.filter(Boolean);
deleted = [...deleted, ...links.map((l) => ({ id: l.id, updated: now }))];
links = imported;
await save();
render();
} catch { alert("Ungültige JSON-Datei."); }
e.target.value = "";
});
load();
$("search").focus();