From eb70f0ce8168efb3155e058090a0ee1de8e7e8cd Mon Sep 17 00:00:00 2001 From: jensmohr Date: Wed, 10 Jun 2026 18:00:44 +0200 Subject: [PATCH] Improve GVL explorer usability and evidence workflows --- .gitignore | 3 + manifest.json | 1 + src/background.js | 320 +++++++++++++++ src/gvl-explorer/gvl-explorer.css | 153 +++++++ src/gvl-explorer/gvl-explorer.html | 17 + src/gvl-explorer/gvl-explorer.js | 618 ++++++++++++++++++++++++++++- 6 files changed, 1090 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 50db8fe..dce16be 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ web-ext-artifacts/ # Environment files .env .env.* + +# Local GVL evidence exports +data/GVL-Dumps/*.json diff --git a/manifest.json b/manifest.json index 9ab3e82..396897e 100644 --- a/manifest.json +++ b/manifest.json @@ -23,6 +23,7 @@ "src/background/db/db-constants.js", "src/background/db/db-core.js", "src/core/evidence-export-json.js", + "src/core/gvl-evidence-json.js", "src/background/gvl/gvl-vendor-normalizer.js", "src/background/gvl/gvl-vendor-relationship-normalizer.js", "src/background/gvl/gvl-catalog-normalizer.js", diff --git a/src/background.js b/src/background.js index f2a2fe5..f5c3bb3 100644 --- a/src/background.js +++ b/src/background.js @@ -29,6 +29,22 @@ async function handleVendorGetMessage(message, sender) { return null; } + if (message.type === "export_gvl_evidence_json") { + return handleExportGvlEvidenceJsonMessage(); + } + + if (message.type === "export_gvl_revision_evidence_json") { + return handleExportGvlRevisionEvidenceJsonMessage(message); + } + + if (message.type === "verify_gvl_revision_evidence_json") { + return handleVerifyGvlRevisionEvidenceJsonMessage(message); + } + + if (message.type === "import_gvl_evidence_json") { + return handleImportGvlEvidenceJsonMessage(message); + } + if (message.type === "gvl_import_json") { return handleGvlImportJsonMessage(message); } @@ -196,6 +212,59 @@ async function handleExportEvidenceJsonMessage() { }; } +async function handleExportGvlEvidenceJsonMessage() { + return { + success: true, + export: await exportVendorGetGvlEvidenceJson() + }; +} + +async function handleExportGvlRevisionEvidenceJsonMessage(message) { + try { + return { + success: true, + export: await exportVendorGetGvlRevisionEvidenceJson( + message?.payload?.snapshotSha256 ?? null + ) + }; + } catch (error) { + return { + success: false, + error: error?.message ?? String(error) + }; + } +} + +async function handleVerifyGvlRevisionEvidenceJsonMessage(message) { + try { + return { + success: true, + verification: await verifyVendorGetGvlRevisionEvidenceJson( + message?.payload?.export ?? null + ) + }; + } catch (error) { + return { + success: false, + error: error?.message ?? String(error) + }; + } +} + +async function handleImportGvlEvidenceJsonMessage(message) { + try { + return { + success: true, + import: await importVendorGetGvlEvidenceJson(message?.payload?.export) + }; + } catch (error) { + return { + success: false, + error: error?.message ?? String(error) + }; + } +} + async function handleGetLatestGvlUpdateStatusMessage() { const db = await openVendorGetDb(); const latestSnapshot = await getLatestGvlSnapshotByVendorListVersion(db); @@ -445,11 +514,13 @@ async function handleGetGvlVendorDetailMessage(message) { const rawEvidence = rawGvlSha256 ? await getGvlRawEvidenceBySha256(db, rawGvlSha256) : null; + const gvlInfo = await getGvlVendorDetailGvlInfo(db, vendorRecord); return { success: true, vendorDetail: { vendor: vendorRecord, + gvlInfo, snapshot: buildGvlVendorDetailSnapshotSummary(snapshot, snapshotSha256), rawEvidence: buildGvlVendorDetailRawEvidenceSummary( rawEvidence, @@ -459,6 +530,255 @@ async function handleGetGvlVendorDetailMessage(message) { }; } +async function getGvlVendorDetailGvlInfo(db, vendorRecord) { + const vendorListVersion = vendorRecord?.vendorListVersion ?? null; + const vendorId = vendorRecord?.vendorId ?? null; + + if (vendorListVersion === null || vendorId === null) { + return buildEmptyGvlVendorDetailGvlInfo(); + } + + const relationships = await getGvlVendorRelationshipsForVendor( + db, + vendorListVersion, + vendorId + ); + const catalogs = await getGvlVendorDetailCatalogs(db, vendorListVersion); + const rawVendor = vendorRecord?.rawVendor ?? {}; + + return { + purposes: buildGvlVendorDetailCatalogList( + rawVendor.purposes, + relationships.purpose, + catalogs.purposes + ), + legIntPurposes: buildGvlVendorDetailCatalogList( + rawVendor.legIntPurposes, + relationships.legIntPurpose, + catalogs.purposes + ), + flexiblePurposes: buildGvlVendorDetailCatalogList( + rawVendor.flexiblePurposes, + relationships.flexiblePurpose, + catalogs.purposes + ), + specialPurposes: buildGvlVendorDetailCatalogList( + rawVendor.specialPurposes, + relationships.specialPurpose, + catalogs.specialPurposes + ), + features: buildGvlVendorDetailCatalogList( + rawVendor.features, + relationships.feature, + catalogs.features + ), + specialFeatures: buildGvlVendorDetailCatalogList( + rawVendor.specialFeatures, + relationships.specialFeature, + catalogs.specialFeatures + ), + dataDeclaration: buildGvlVendorDetailCatalogList( + rawVendor.dataDeclaration, + [], + catalogs.dataCategories + ), + dataCategoriesTextAvailable: catalogs.dataCategories.size > 0, + dataRetention: rawVendor.dataRetention ?? null, + overflow: rawVendor.overflow ?? null + }; +} + +function buildEmptyGvlVendorDetailGvlInfo() { + return { + purposes: [], + legIntPurposes: [], + flexiblePurposes: [], + specialPurposes: [], + features: [], + specialFeatures: [], + dataDeclaration: [], + dataCategoriesTextAvailable: false, + dataRetention: null, + overflow: null + }; +} + +function getGvlVendorRelationshipsForVendor(db, vendorListVersion, vendorId) { + return new Promise((resolve, reject) => { + const storeName = VENDORGET_STORE_NAMES.gvlVendorRelationships; + const tx = db.transaction([storeName], "readonly"); + const relationshipsStore = tx.objectStore(storeName); + const vendorIdIndex = relationshipsStore.index("vendorId"); + const cursorRequest = vendorIdIndex.openCursor(IDBKeyRange.only(vendorId)); + const relationships = {}; + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + const record = cursor.value; + + if (record?.vendorListVersion === vendorListVersion) { + const relationshipType = record.relationshipType ?? "unknown"; + + if (!relationships[relationshipType]) { + relationships[relationshipType] = []; + } + + relationships[relationshipType].push(record.relatedId); + } + + cursor.continue(); + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(relationships); + }); +} + +async function getGvlVendorDetailCatalogs(db, vendorListVersion) { + const [ + purposes, + specialPurposes, + features, + specialFeatures, + dataCategories + ] = await Promise.all([ + getGvlCatalogMapForVersion( + db, + VENDORGET_STORE_NAMES.gvlPurposes, + vendorListVersion + ), + getGvlCatalogMapForVersion( + db, + VENDORGET_STORE_NAMES.gvlSpecialPurposes, + vendorListVersion + ), + getGvlCatalogMapForVersion( + db, + VENDORGET_STORE_NAMES.gvlFeatures, + vendorListVersion + ), + getGvlCatalogMapForVersion( + db, + VENDORGET_STORE_NAMES.gvlSpecialFeatures, + vendorListVersion + ), + getGvlCatalogMapForVersion( + db, + VENDORGET_STORE_NAMES.gvlDataCategories, + vendorListVersion + ) + ]); + + return { + purposes, + specialPurposes, + features, + specialFeatures, + dataCategories + }; +} + +function getGvlCatalogMapForVersion(db, storeName, vendorListVersion) { + return new Promise((resolve, reject) => { + const tx = db.transaction([storeName], "readonly"); + const catalogStore = tx.objectStore(storeName); + const vendorListVersionIndex = catalogStore.index("vendorListVersion"); + const cursorRequest = vendorListVersionIndex.openCursor( + IDBKeyRange.only(vendorListVersion) + ); + const catalogMap = new Map(); + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + const record = cursor.value; + const catalogId = getGvlCatalogRecordId(record); + + if (catalogId !== null) { + catalogMap.set(catalogId, record); + } + + cursor.continue(); + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(catalogMap); + }); +} + +function getGvlCatalogRecordId(record) { + const catalogId = + record?.catalogId ?? + record?.purposeId ?? + record?.specialPurposeId ?? + record?.featureId ?? + record?.specialFeatureId ?? + record?.dataCategoryId ?? + null; + + if (catalogId === null || catalogId === undefined) { + return null; + } + + const numericId = Number(catalogId); + + return Number.isFinite(numericId) ? numericId : null; +} + +function buildGvlVendorDetailCatalogList(rawIds, relationshipIds, catalogMap) { + const ids = mergeGvlVendorDetailIds(rawIds, relationshipIds); + + return ids.map((id) => { + const catalogRecord = catalogMap.get(id) ?? null; + + return { + id, + name: catalogRecord?.name ?? null, + description: catalogRecord?.description ?? null, + descriptionLegal: catalogRecord?.descriptionLegal ?? null, + illustrations: catalogRecord?.illustrations ?? null, + rawCatalog: catalogRecord?.rawCatalog ?? null + }; + }); +} + +function mergeGvlVendorDetailIds(...sources) { + const ids = []; + const seen = new Set(); + + sources.forEach((source) => { + if (!Array.isArray(source)) { + return; + } + + source.forEach((value) => { + const id = Number(value); + + if (!Number.isFinite(id) || seen.has(id)) { + return; + } + + seen.add(id); + ids.push(id); + }); + }); + + return ids; +} + function parseGvlVendorDetailId(value) { const vendorId = Number(value); diff --git a/src/gvl-explorer/gvl-explorer.css b/src/gvl-explorer/gvl-explorer.css index a485568..26daeb2 100644 --- a/src/gvl-explorer/gvl-explorer.css +++ b/src/gvl-explorer/gvl-explorer.css @@ -90,6 +90,37 @@ p { color: #cbd5e1; } +.evidence-status { + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 4px; + overflow-wrap: anywhere; +} + +.evidence-status.is-neutral { + color: #cbd5e1; + border-color: #334155; + background: #1f2937; +} + +.evidence-status.is-success { + color: #bbf7d0; + border-color: #166534; + background: #052e16; +} + +.evidence-status.is-warning { + color: #fde68a; + border-color: #92400e; + background: #422006; +} + +.evidence-status.is-error { + color: #fecaca; + border-color: #991b1b; + background: #450a0a; +} + .vendor-detail-form { display: grid; grid-template-columns: auto minmax(120px, 180px) auto; @@ -137,11 +168,40 @@ button { background: #1f2937; } +.file-action { + display: inline-block; + padding: 8px 10px; + border: 1px solid #475569; + border-radius: 4px; + font: inherit; + font-size: 13px; + color: #e5edf5; + background: #1f2937; + cursor: pointer; +} + +.file-action.is-disabled { + cursor: default; + opacity: 0.65; + pointer-events: none; +} + button:disabled { cursor: default; opacity: 0.65; } +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + table { width: 100%; border-collapse: collapse; @@ -211,11 +271,100 @@ th { margin-top: 14px; } +.vendor-detail-panel { + font-size: 13px; + color: #cbd5e1; +} + +.vendor-detail-panel > summary, +.subject-details > summary { + cursor: pointer; + margin-bottom: 12px; + font-weight: 700; +} + .vendor-detail-section h3 { margin: 0 0 8px; font-size: 13px; } +.summary-table td { + white-space: pre-wrap; +} + +.subject-details { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid #334155; +} + +.gvl-catalog-section { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.gvl-catalog-section h4 { + margin: 0; + font-size: 13px; + color: #e5edf5; +} + +.catalog-list { + display: grid; + gap: 6px; +} + +.catalog-item { + border: 1px solid #334155; + border-radius: 4px; + background: #182231; +} + +.catalog-item summary { + cursor: pointer; + padding: 8px 10px; + color: #e5edf5; + font-weight: 700; +} + +.definition-list { + display: grid; + grid-template-columns: minmax(120px, 180px) 1fr; + gap: 0; + margin: 0; + border-top: 1px solid #334155; +} + +.definition-list dt, +.definition-list dd { + margin: 0; + padding: 8px 10px; + border-bottom: 1px solid #334155; + overflow-wrap: anywhere; +} + +.definition-list dt { + color: #cbd5e1; + font-weight: 700; + background: #0f172a; +} + +.definition-list dd { + color: #e5edf5; + white-space: pre-wrap; +} + +.muted-text { + max-width: none; + color: #94a3b8; +} + +.secondary-action { + width: fit-content; + margin-top: 12px; +} + .empty-state { padding: 10px 12px; border: 1px solid #334155; @@ -258,4 +407,8 @@ th { .vendor-detail-form { grid-template-columns: 1fr; } + + .definition-list { + grid-template-columns: 1fr; + } } diff --git a/src/gvl-explorer/gvl-explorer.html b/src/gvl-explorer/gvl-explorer.html index d21a028..1a618f6 100644 --- a/src/gvl-explorer/gvl-explorer.html +++ b/src/gvl-explorer/gvl-explorer.html @@ -24,10 +24,27 @@ + + + Bereit +
diff --git a/src/gvl-explorer/gvl-explorer.js b/src/gvl-explorer/gvl-explorer.js index 8ffa3d0..fac95f8 100644 --- a/src/gvl-explorer/gvl-explorer.js +++ b/src/gvl-explorer/gvl-explorer.js @@ -9,6 +9,15 @@ const gvlDebugData = document.getElementById("gvl-debug-data"); const gvlFetchOfficialButton = document.getElementById( "gvl-fetch-official-button" ); +const gvlRevisionEvidenceExportButton = document.getElementById( + "gvl-revision-evidence-export-button" +); +const gvlRevisionEvidenceVerifyInput = document.getElementById( + "gvl-revision-evidence-verify-input" +); +const gvlEvidenceTransportStatus = document.getElementById( + "gvl-evidence-transport-status" +); const gvlFetchStatus = document.getElementById("gvl-fetch-status"); const gvlRebuildNormalizedButton = document.getElementById( "gvl-rebuild-normalized-button" @@ -53,6 +62,14 @@ document.addEventListener("DOMContentLoaded", async () => { await fetchOfficialGvl(); }); + gvlRevisionEvidenceExportButton.addEventListener("click", async () => { + await exportSelectedGvlRevisionEvidenceJsonFile(); + }); + + gvlRevisionEvidenceVerifyInput.addEventListener("change", async () => { + await verifyGvlRevisionEvidenceJsonFile(); + }); + gvlRebuildNormalizedButton.addEventListener("click", async () => { await rebuildSelectedGvlSnapshotNormalizedData(); }); @@ -112,10 +129,230 @@ function renderFetchStatus(message) { gvlFetchStatus.textContent = message; } +function renderGvlEvidenceTransportStatus(message, statusKind = "neutral") { + gvlEvidenceTransportStatus.textContent = message; + gvlEvidenceTransportStatus.className = [ + "fetch-status", + "evidence-status", + `is-${statusKind}` + ].join(" "); +} + function renderRebuildStatus(message) { gvlRebuildNormalizedStatus.textContent = message; } +async function exportSelectedGvlRevisionEvidenceJsonFile() { + const snapshot = findGvlSnapshot(selectedSnapshotSha256); + + if (!snapshot) { + renderGvlEvidenceTransportStatus( + "Keine GVL-Revision ausgewählt.", + "warning" + ); + return; + } + + gvlRevisionEvidenceExportButton.disabled = true; + renderGvlEvidenceTransportStatus("GVL-Revision-Export läuft..."); + + try { + const result = await browser.runtime.sendMessage({ + type: "export_gvl_revision_evidence_json", + payload: { + snapshotSha256: snapshot.sha256 + } + }); + + if (!result?.success || !result.export) { + throw new Error( + result?.error ?? "export_gvl_revision_evidence_json_failed" + ); + } + + renderGvlEvidenceTransportStatus( + "GVL-Revision-Export wird intern verifiziert..." + ); + + const verification = await verifyGeneratedGvlRevisionEvidenceExport( + result.export + ); + + if (!verification.valid) { + renderGvlEvidenceTransportStatus( + [ + "GVL-Revision-Export nicht erzeugt: interne Verifikation fehlgeschlagen.", + buildGvlRevisionEvidenceVerificationMessage(verification) + ].join(" "), + "error" + ); + return; + } + + downloadGvlRevisionEvidenceJsonExport(result.export); + renderGvlEvidenceTransportStatus( + [ + "GVL-Revision exportiert und intern verifiziert.", + buildGvlRevisionEvidenceExportSuccessMessage(result.export) + ].join(" "), + "success" + ); + } catch (error) { + renderGvlEvidenceTransportStatus( + "GVL-Revision-Export fehlgeschlagen.", + "error" + ); + console.warn("VG-Observe GVL revision evidence export failed", error); + } finally { + gvlRevisionEvidenceExportButton.disabled = false; + } +} + +async function verifyGeneratedGvlRevisionEvidenceExport(exportContainer) { + const result = await browser.runtime.sendMessage({ + type: "verify_gvl_revision_evidence_json", + payload: { + export: exportContainer + } + }); + + if (!result?.success || !result.verification) { + throw new Error( + result?.error ?? "verify_gvl_revision_evidence_json_failed" + ); + } + + return result.verification; +} + +function downloadGvlRevisionEvidenceJsonExport(exportContainer) { + const json = JSON.stringify(exportContainer, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const downloadLink = document.createElement("a"); + const metadata = exportContainer?.metadata ?? {}; + const vendorListVersion = metadata.vendorListVersion ?? "unknown"; + const exportedAtUtcCompact = + metadata.exportedAtUtcCompact ?? formatExportTimestampUtcCompact(new Date()); + + downloadLink.href = url; + downloadLink.download = `GVL-REV-${vendorListVersion}-${exportedAtUtcCompact}.json`; + document.body.append(downloadLink); + downloadLink.click(); + downloadLink.remove(); + + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function buildGvlRevisionEvidenceExportSuccessMessage(exportContainer) { + const metadata = exportContainer?.metadata ?? {}; + const recordCount = getGvlRevisionEvidenceNormalizedRecordCount( + exportContainer?.normalized + ); + + return [ + "GVL-Revision exportiert:", + `Vendorlisten-Version ${formatNullable(metadata.vendorListVersion)}`, + `Snapshot ${shortenSha256(metadata.snapshotSha256)}`, + `Raw-GVL ${shortenSha256(metadata.rawGvlSha256)}`, + `Payload ${shortenSha256(metadata.exportPayloadSha256)}`, + `${recordCount} normalisierte Records.` + ].join(" "); +} + +function getGvlRevisionEvidenceNormalizedRecordCount(normalized) { + return [ + "gvl_vendors", + "gvl_purposes", + "gvl_special_purposes", + "gvl_features", + "gvl_special_features", + "gvl_data_categories", + "gvl_vendor_relationships" + ].reduce((total, storeName) => { + return total + (normalized?.[storeName]?.length ?? 0); + }, 0); +} + +async function verifyGvlRevisionEvidenceJsonFile() { + const file = gvlRevisionEvidenceVerifyInput.files?.[0] ?? null; + + if (!file) { + return; + } + + setGvlRevisionEvidenceVerifyDisabled(true); + renderGvlEvidenceTransportStatus("GVL-Revision-Export wird verifiziert..."); + + try { + const exportContainer = JSON.parse(await file.text()); + const verification = await verifyGeneratedGvlRevisionEvidenceExport( + exportContainer + ); + + renderGvlEvidenceTransportStatus( + buildGvlRevisionEvidenceVerificationMessage(verification), + verification.valid ? "success" : "error" + ); + } catch (error) { + renderGvlEvidenceTransportStatus( + "GVL-Revision-Export ist nicht valide.", + "error" + ); + console.warn("VG-Observe GVL revision evidence verification failed", error); + } finally { + gvlRevisionEvidenceVerifyInput.value = ""; + setGvlRevisionEvidenceVerifyDisabled(false); + } +} + +function setGvlRevisionEvidenceVerifyDisabled(disabled) { + const importLabel = document.querySelector( + "label[for='gvl-revision-evidence-verify-input']" + ); + + gvlRevisionEvidenceVerifyInput.disabled = disabled; + importLabel?.classList.toggle("is-disabled", disabled); +} + +function buildGvlRevisionEvidenceVerificationMessage(verification) { + const validityLabel = verification.valid ? "valide" : "nicht valide"; + const counts = verification.normalizedCounts ?? {}; + const normalizedRecordCount = Object.values(counts).reduce((total, count) => { + return total + Number(count ?? 0); + }, 0); + + return [ + `Verifikation: ${validityLabel}.`, + `Vendorlisten-Version ${formatNullable(verification.vendorListVersion)}.`, + `Snapshot ${shortenSha256(verification.snapshotSha256)}.`, + `Raw-GVL ${shortenSha256(verification.rawGvlSha256)}.`, + `Payload ${shortenSha256(verification.exportPayloadSha256)}.`, + `Normalisierte Records: ${normalizedRecordCount} (${formatNormalizedCounts( + counts + )}).`, + verification.valid + ? "" + : `Fehler: ${(verification.errors ?? []).join(", ")}.` + ] + .filter(Boolean) + .join(" "); +} + +function formatNormalizedCounts(counts) { + return [ + ["Vendoren", counts.gvl_vendors], + ["Purposes", counts.gvl_purposes], + ["Special Purposes", counts.gvl_special_purposes], + ["Features", counts.gvl_features], + ["Special Features", counts.gvl_special_features], + ["Data Categories", counts.gvl_data_categories], + ["Vendor-Beziehungen", counts.gvl_vendor_relationships] + ] + .map(([label, count]) => `${label}: ${Number(count ?? 0)}`) + .join(", "); +} + async function rebuildSelectedGvlSnapshotNormalizedData() { const snapshot = findGvlSnapshot(selectedSnapshotSha256); @@ -213,43 +450,292 @@ function renderGvlVendorDetailResult(detail) { const vendor = detail.vendor ?? {}; const snapshot = detail.snapshot ?? {}; const rawEvidence = detail.rawEvidence ?? {}; + const gvlInfo = detail.gvlInfo ?? {}; gvlVendorDetailResult.textContent = ""; gvlVendorDetailResult.append( - buildKeyValueSection("Normalisierte Vendor-Felder", [ - ["Vendor-ID", formatNullable(vendor.vendorId)], - ["Name", formatNullable(vendor.name)], - ["Policy URL", formatNullable(vendor.policyUrl)], - ["Deleted Date", formatNullable(vendor.deletedDate)], - ["Uses Cookies", formatNullable(vendor.usesCookies)], - ["Cookie Max Age Seconds", formatNullable(vendor.cookieMaxAgeSeconds)], - ["Uses Non-Cookie Access", formatNullable(vendor.usesNonCookieAccess)], - [ - "Device Storage Disclosure URL", - formatNullable(vendor.deviceStorageDisclosureUrl) - ], - ["Domains", formatJsonValue(vendor.domains)], - ["Snapshot SHA256", formatNullable(vendor.snapshotSha256)] - ]), - buildKeyValueSection("Snapshot-Herkunft", [ + buildVendorDetailPanel(vendor, snapshot, rawEvidence, gvlInfo) + ); +} + +function buildVendorDetailPanel(vendor, snapshot, rawEvidence, gvlInfo) { + const details = document.createElement("details"); + const summary = document.createElement("summary"); + const closeButton = document.createElement("button"); + + details.className = "vendor-detail-panel"; + details.open = true; + summary.textContent = `Vendor-Details: ${formatNullable( + vendor.name + )} (ID ${formatNullable(vendor.vendorId)})`; + + closeButton.type = "button"; + closeButton.className = "secondary-action"; + closeButton.textContent = "Vendor-Details schließen"; + closeButton.addEventListener("click", () => { + details.open = false; + }); + + details.append( + summary, + buildHumanReadableVendorCard(vendor), + buildGvlSubjectMatterDetails(gvlInfo), + buildTechnicalEvidenceDetails(vendor, snapshot, rawEvidence), + closeButton + ); + + return details; +} + +function buildHumanReadableVendorCard(vendor) { + const rawVendor = vendor.rawVendor ?? {}; + const urlRows = getVendorUrlRows(rawVendor, vendor); + + return buildKeyValueSection("Vendor-Karte", [ + ["Vendor-Name", formatNullable(vendor.name ?? rawVendor.name)], + ["Vendor-ID", formatNullable(vendor.vendorId ?? rawVendor.id)], + ["Status", formatVendorStatus(vendor.deletedDate ?? rawVendor.deletedDate)], + ["Privacy-/Datenschutz-URLs", formatMultilineValue(urlRows.privacy)], + [ + "Legitimate-Interest-Claim-URLs", + formatMultilineValue(urlRows.legitimateInterest) + ], + [ + "Device-Storage-Disclosure-URL", + formatNullable( + vendor.deviceStorageDisclosureUrl ?? + rawVendor.deviceStorageDisclosureUrl + ) + ], + ["Nutzt Cookies", formatBooleanGerman(vendor.usesCookies)], + [ + "Cookie Refresh", + formatBooleanGerman(rawVendor.cookieRefresh ?? vendor.cookieRefresh) + ], + [ + "Cookie Max Age", + formatCookieMaxAge(vendor.cookieMaxAgeSeconds) + ], + [ + "Non-Cookie Access", + formatBooleanGerman(vendor.usesNonCookieAccess) + ], + ["Domains", formatArrayValue(vendor.domains ?? rawVendor.domains)] + ]); +} + +function getVendorUrlRows(rawVendor, vendor) { + const urls = Array.isArray(rawVendor.urls) ? rawVendor.urls : []; + const privacy = []; + const legitimateInterest = []; + + urls.forEach((urlEntry) => { + if (!urlEntry || typeof urlEntry !== "object") { + return; + } + + const label = urlEntry.langId ? `${urlEntry.langId}: ` : ""; + + appendUrlIfPresent(privacy, `${label}${urlEntry.privacy}`); + appendUrlIfPresent( + legitimateInterest, + `${label}${urlEntry.legIntClaim}` + ); + appendUrlIfPresent( + legitimateInterest, + `${label}${urlEntry.legitimateInterestClaim}` + ); + }); + + appendUrlIfPresent(privacy, vendor.policyUrl ?? rawVendor.policyUrl); + appendUrlIfPresent( + legitimateInterest, + vendor.legitimateInterestDisclosureUrl ?? + rawVendor.legitimateInterestDisclosureUrl + ); + + return { + privacy: uniqueValues(privacy), + legitimateInterest: uniqueValues(legitimateInterest) + }; +} + +function appendUrlIfPresent(values, value) { + if (!value) { + return; + } + + const urlValue = String(value); + + if (urlValue.endsWith("undefined") || urlValue.endsWith("null")) { + return; + } + + values.push(urlValue); +} + +function uniqueValues(values) { + return Array.from(new Set(values)); +} + +function buildGvlSubjectMatterDetails(gvlInfo) { + const details = document.createElement("details"); + const summary = document.createElement("summary"); + + details.className = "subject-details"; + details.open = true; + summary.textContent = "GVL-Fachinformationen"; + details.append( + summary, + buildCatalogListSection("Purposes", "Purpose", gvlInfo.purposes), + buildCatalogListSection( + "Legitimate-Interest-Purposes", + "Purpose", + gvlInfo.legIntPurposes + ), + buildCatalogListSection( + "Flexible Purposes", + "Purpose", + gvlInfo.flexiblePurposes + ), + buildCatalogListSection( + "Special Purposes", + "Special Purpose", + gvlInfo.specialPurposes + ), + buildCatalogListSection("Features", "Feature", gvlInfo.features), + buildCatalogListSection( + "Special Features", + "Special Feature", + gvlInfo.specialFeatures + ), + buildDataDeclarationSection(gvlInfo), + buildJsonDetails("Data Retention", gvlInfo.dataRetention), + buildJsonDetails("Overflow", gvlInfo.overflow) + ); + + return details; +} + +function buildCatalogListSection(title, entityLabel, items) { + const section = document.createElement("section"); + const heading = document.createElement("h4"); + const list = document.createElement("div"); + const catalogItems = Array.isArray(items) ? items : []; + + section.className = "gvl-catalog-section"; + heading.textContent = title; + list.className = "catalog-list"; + section.append(heading); + + if (!catalogItems.length) { + const empty = document.createElement("p"); + + empty.className = "muted-text"; + empty.textContent = "Keine Angaben vorhanden."; + section.append(empty); + return section; + } + + catalogItems.forEach((item) => { + list.append(buildCatalogItemDetails(entityLabel, item)); + }); + section.append(list); + + return section; +} + +function buildCatalogItemDetails(entityLabel, item) { + const details = document.createElement("details"); + const summary = document.createElement("summary"); + const rows = [ + ["ID", formatNullable(item?.id)], + ["Name", formatNullable(item?.name)], + ["Description", formatNullable(item?.description)], + ["Description Legal", formatNullable(item?.descriptionLegal)], + ["Illustrations", formatJsonValue(item?.illustrations)] + ]; + + details.className = "catalog-item"; + summary.textContent = formatCatalogItemSummary(entityLabel, item); + details.append(summary, buildDefinitionList(rows)); + + return details; +} + +function formatCatalogItemSummary(entityLabel, item) { + const prefix = `${entityLabel} ${formatNullable(item?.id)}`; + + if (!item?.name) { + return `${prefix} - Klartext lokal nicht verfügbar`; + } + + return `${prefix} - ${item.name}`; +} + +function buildDataDeclarationSection(gvlInfo) { + const section = buildCatalogListSection( + "Data Declaration", + "Data Category", + gvlInfo.dataDeclaration + ); + + if (!gvlInfo.dataCategoriesTextAvailable) { + const note = document.createElement("p"); + + note.className = "muted-text"; + note.textContent = "Klartext derzeit nicht normalisiert verfügbar."; + section.prepend(note); + } + + return section; +} + +function buildTechnicalEvidenceDetails(vendor, snapshot, rawEvidence) { + const details = document.createElement("details"); + const summary = document.createElement("summary"); + + details.className = "technical-details"; + summary.textContent = "Technischer Evidence-Nachweis"; + details.append( + summary, + buildKeyValueSection("Snapshot und Raw-GVL", [ ["Snapshot SHA256", formatNullable(snapshot.snapshotSha256)], ["Raw-GVL SHA256", formatNullable(snapshot.rawGvlSha256)], ["Vendorlisten-Version", formatNullable(snapshot.vendorListVersion)], ["TCF Policy Version", formatNullable(snapshot.tcfPolicyVersion)], - ["Abrufzeitpunkt", formatNullable(snapshot.fetchedAt)], - ["Snapshot erstellt", formatNullable(snapshot.createdAt)] + ["Fetched At", formatNullable(snapshot.fetchedAt)], + ["Snapshot createdAt", formatNullable(snapshot.createdAt)] ]), buildKeyValueSection("Raw-GVL-Evidence", [ - ["Raw-GVL SHA256", formatNullable(rawEvidence.rawGvlSha256)], - ["Quelle", formatNullable(rawEvidence.sourceUrl)], - ["Abrufzeitpunkt", formatNullable(rawEvidence.fetchedAt)], + ["Source URL", formatNullable(rawEvidence.sourceUrl)], + ["Fetched At", formatNullable(rawEvidence.fetchedAt)], ["HTTP Status", formatNullable(rawEvidence.httpStatus)], ["Content-Type", formatNullable(rawEvidence.contentType)], - ["Raw Body vorhanden", formatNullable(rawEvidence.hasRawBody)] + ["hasRawBody", formatNullable(rawEvidence.hasRawBody)] ]), buildJsonDetails("Vollständiger rawVendor-Datensatz", vendor.rawVendor), buildJsonDetails("Vollständiger gvl_vendors-Datensatz", vendor) ); + + return details; +} + +function buildDefinitionList(rows) { + const list = document.createElement("dl"); + + list.className = "definition-list"; + + rows.forEach(([label, value]) => { + const term = document.createElement("dt"); + const description = document.createElement("dd"); + + term.textContent = label; + description.textContent = value; + list.append(term, description); + }); + + return list; } function buildKeyValueSection(title, rows) { @@ -293,6 +779,79 @@ function buildJsonDetails(title, value) { return details; } +function formatVendorStatus(deletedDate) { + if (deletedDate) { + return `Gelöscht seit ${deletedDate}`; + } + + return "Aktiv"; +} + +function formatBooleanGerman(value) { + if (value === true) { + return "ja"; + } + + if (value === false) { + return "nein"; + } + + return "-"; +} + +function formatCookieMaxAge(value) { + if (value === null || value === undefined || value === "") { + return "-"; + } + + const seconds = Number(value); + + if (!Number.isFinite(seconds)) { + return formatNullable(value); + } + + const humanReadable = formatDurationFromSeconds(seconds); + + return humanReadable + ? `${seconds} Sekunden (${humanReadable})` + : `${seconds} Sekunden`; +} + +function formatDurationFromSeconds(seconds) { + const units = [ + ["Jahr", "Jahre", 365 * 24 * 60 * 60], + ["Tag", "Tage", 24 * 60 * 60], + ["Stunde", "Stunden", 60 * 60], + ["Minute", "Minuten", 60] + ]; + + for (const [singular, plural, unitSeconds] of units) { + if (seconds >= unitSeconds && seconds % unitSeconds === 0) { + const amount = seconds / unitSeconds; + + return `${amount} ${amount === 1 ? singular : plural}`; + } + } + + return null; +} + +function formatArrayValue(value) { + if (!Array.isArray(value) || !value.length) { + return "-"; + } + + return value.join("\n"); +} + +function formatMultilineValue(values) { + if (!Array.isArray(values) || !values.length) { + return "-"; + } + + return values.join("\n"); +} + async function renderGvlSnapshots() { try { const result = await browser.runtime.sendMessage({ @@ -661,3 +1220,18 @@ function shortenSha256(value) { return `${String(value).slice(0, 12)}...`; } + +function formatExportTimestampUtcCompact(date) { + const year = date.getUTCFullYear(); + const month = padDatePart(date.getUTCMonth() + 1); + const day = padDatePart(date.getUTCDate()); + const hours = padDatePart(date.getUTCHours()); + const minutes = padDatePart(date.getUTCMinutes()); + const seconds = padDatePart(date.getUTCSeconds()); + + return `${year}${month}${day}T${hours}${minutes}${seconds}Z`; +} + +function padDatePart(value) { + return String(value).padStart(2, "0"); +}