diff --git a/.env.example b/.env.example index c9ac807..1ed3d8b 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,12 @@ PUBLIC_NAME=AI Usage Disclosure CONTACT_URL=https://ai.trustednet.eu SALES_URL=https://ai.trustednet.eu/product DEFAULT_LANGUAGE=de +BULK_URL=http://localhost:8081 + +# Optional second container for batch rendering. +BULK_MAX_URLS=500 +BULK_WORKERS=4 +BULK_REQUEST_TIMEOUT=8s # Proxy and logging. Enable proxy headers only with explicit trusted CIDRs. TRUST_PROXY=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 5093658..ae41d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.8.0 - 2026-07-22 + +- Added the optional `ai-disclosure-bulk` container without accounts, database or separate legal decision logic. +- Added browser-only website profiles for recurring author, editorial-responsibility, imprint/reference and complaint-contact data. +- Added browser-only saved disclosure templates with JSON export/import and complete local-data deletion. +- Added direct generator hand-off via a URL fragment so a configured disclosure can be opened as a bulk template without sending the template fragment to the server. +- Added line-based URL/path import with optional base URL, duplicate removal, validation and a configurable batch limit. +- Added bulk HTML, Markdown, JSON-LD array, JSONL and CSV exports. +- Added core endpoint `GET /v1/render` so all bulk markup and JSON-LD are rendered by the existing disclosure core. +- Bulk processing never fetches the supplied content URLs; only the configured core origin is contacted. +- Updated the privacy notice to document the optional bulk container's Local Storage use. + ## 1.7.0 - 2026-07-22 - Rebuilt the generator as a dependency-driven workflow: fields are shown only when the selected content and legal context make them relevant. diff --git a/Dockerfile.bulk b/Dockerfile.bulk new file mode 100644 index 0000000..39c8019 --- /dev/null +++ b/Dockerfile.bulk @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1.7 +FROM golang:1.26-alpine AS build +WORKDIR /src +RUN apk add --no-cache ca-certificates tzdata +COPY go.mod ./ +COPY third_party ./third_party +COPY cmd ./cmd +COPY internal ./internal +COPY bulkweb ./bulkweb +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure-bulk ./cmd/bulk + +FROM scratch +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=build /out/ai-disclosure-bulk /ai-disclosure-bulk +USER 65532:65532 +EXPOSE 8081 +ENV BULK_LISTEN_ADDRESS=:8081 CORE_INTERNAL_URL=http://app:8080 DISCLOSURE_BASE_URL=http://localhost:8080 GENERATOR_URL=http://localhost:8080 +ENTRYPOINT ["/ai-disclosure-bulk"] diff --git a/Makefile b/Makefile index 9fa37ef..6781a04 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ -.PHONY: run test test-license-client check build docker-build +.PHONY: run run-bulk test test-license-client check build build-bulk docker-build docker-build-bulk run: go run ./cmd/server +run-bulk: + go run ./cmd/bulk + test: go test ./... cd third_party/license-platform-client && go test ./... @@ -11,7 +14,7 @@ test-license-client: cd third_party/license-platform-client && go test ./... check: - gofmt -w $$(find cmd internal web third_party/license-platform-client -name '*.go' -type f) + gofmt -w $$(find cmd internal web bulkweb third_party/license-platform-client -name '*.go' -type f) go vet ./... go test -race ./... cd third_party/license-platform-client && go vet ./... && go test -race ./... @@ -20,5 +23,12 @@ build: mkdir -p bin CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure ./cmd/server +build-bulk: + mkdir -p bin + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure-bulk ./cmd/bulk + docker-build: - docker build -t ai-disclosure-standard:1.7.0-local . + docker build -t ai-disclosure-standard:1.8.0-local . + +docker-build-bulk: + docker build -f Dockerfile.bulk -t ai-disclosure-bulk:1.0.0-local . diff --git a/README.md b/README.md index 516df39..87a5a20 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AI Disclosure Standard 1.7.0 +# AI Disclosure Standard 1.8.0 Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen. @@ -17,7 +17,8 @@ Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur - konfigurierbare Seiten für Impressum, Datenschutz und Barrierefreiheit; - CSP mit Request-Nonce, minimierte Logs, vertrauensgebundene Proxy-Header und gehärtete Container-Defaults; - Health-, Readiness- und optional geschützter Prometheus-Endpunkt; -- Docker-, Kubernetes- und Docker-Swarm-Deployment. +- Docker-, Kubernetes- und Docker-Swarm-Deployment; +- optionaler zweiter Bulk-Container für wiederverwendbare Vorlagen und bis zu 500 Inhalts-URLs pro Lauf, ohne Benutzerkonten oder Datenbank. ## Start unter Windows @@ -36,6 +37,7 @@ Datenschutz: http://localhost:8080/datenschutz Barrierefrei: http://localhost:8080/barrierefreiheit Healthcheck: http://localhost:8080/healthz Funktionen: http://localhost:8080/v1/capabilities +Bulk: http://localhost:8081/ ``` Go lädt `.env` nicht selbst. Unter PowerShell kann die Datei vor dem Start in die Prozessumgebung übernommen werden oder Docker Compose mit `--env-file .env` verwendet werden. @@ -47,6 +49,35 @@ Copy-Item .env.example .env docker compose --env-file .env up -d --build ``` + +## Bulk-Generator + +Der optionale Container `ai-disclosure-bulk` ist bewusst klein und zustandslos. Er enthält **keine eigene rechtliche Entscheidungslogik**. Stattdessen ruft er für jede Inhalts-URL den Core-Endpunkt `/v1/render` auf und setzt ausschließlich `subject` neu. Dadurch bleiben normaler Generator, API und Bulk-Ausgabe auf demselben Regel- und Renderingstand. + +Typischer Ablauf: + +1. Kennzeichnung im normalen Generator konfigurieren. +2. **Als Bulk-Vorlage öffnen** auswählen. +3. Optional ein Website-Profil mit wiederkehrenden Angaben wie Autor, Impressums-/Verantwortlichkeits-URL und Beschwerdestelle im Browser speichern. +4. Absolute URLs oder relative Pfade einfügen. Für relative Pfade kann einmalig eine Basis-URL gesetzt werden. +5. HTML, Markdown, JSON-LD, JSONL oder CSV erzeugen und kopieren bzw. herunterladen. + +Der Bulk-Container ruft die eingegebenen Inhalts-URLs **nicht** ab. Website-Profile und gespeicherte Kennzeichnungsvorlagen werden ausschließlich im `localStorage` des Browsers gespeichert. URL-Listen und erzeugte Ergebnisse werden weder im Browser dauerhaft gespeichert noch serverseitig persistiert. Profile und Vorlagen können als JSON exportiert, importiert oder vollständig gelöscht werden. + +Relevante Variablen: + +| Variable | Standard | Bedeutung | +|---|---|---| +| `BULK_URL` | leer / in der Beispielkonfiguration `http://localhost:8081` | öffentliche URL des Bulk-Generators; aktiviert den Übergabe-Button im normalen Generator | +| `CORE_INTERNAL_URL` | `http://app:8080` | feste interne Origin des Disclosure-Core; der Bulk-Dienst folgt keinen frei eingegebenen Ziel-URLs | +| `DISCLOSURE_BASE_URL` | `http://localhost:8080` | öffentliche Core-Origin für Links in der Bulk-Oberfläche | +| `GENERATOR_URL` | wie `DISCLOSURE_BASE_URL` | öffentlicher Link zurück zum normalen Generator | +| `BULK_MAX_URLS` | `500` | maximale Zahl an Inhalts-URLs je Lauf, maximal 5000 | +| `BULK_WORKERS` | `4` | parallele Core-Render-Aufrufe, maximal 32 | +| `BULK_REQUEST_TIMEOUT` | `8s` | Timeout je Core-Aufruf | + +Die Vorlagenübergabe vom normalen Generator zum Bulk-Generator erfolgt im URL-Fragment (`#template=...`). Dieses Fragment wird vom Browser nicht als Teil der HTTP-Anfrage an den Server gesendet und nach dem Import aus der Adresszeile entfernt. + ## Lizenzprüfung Dieses Projekt stellt **keine Lizenzen aus** und enthält keine Schlüsselgenerierung, privaten Schlüssel, Lizenzverwaltung, Admin-Oberfläche oder eigenen Lizenzserver. Diese Aufgaben gehören ausschließlich in die separat betriebene **Universal License Platform**. @@ -158,7 +189,7 @@ Die Seiten `/impressum`, `/datenschutz` und `/barrierefreiheit` werden aus Umgeb Weitere Variablen stehen vollständig in [`.env.example`](.env.example). Dazu gehören Vertretungsberechtigte, Register- und Umsatzsteuerangaben, redaktionell Verantwortliche, Datenschutzkontakt, Empfänger, Drittlandübermittlungen, Aufsichtsbehörde, Verbraucherstreitbeilegung und Barrierefreiheitskontakt. -### Lizenzprüfung +## Lizenzprüfung | Variable | Standard | Bedeutung | |---|---|---| @@ -185,6 +216,7 @@ GET /datenschutz GET /barrierefreiheit GET /declaration GET /v1/declaration.json +GET /v1/render HTML, Markdown und JSON-LD aus denselben Parametern POST /v1/validate GET /v1/capabilities GET /healthz diff --git a/bulkweb/embed.go b/bulkweb/embed.go new file mode 100644 index 0000000..0247211 --- /dev/null +++ b/bulkweb/embed.go @@ -0,0 +1,8 @@ +package bulkweb + +import "embed" + +// Files contains the self-contained bulk user interface. +// +//go:embed templates/*.html static/* +var Files embed.FS diff --git a/bulkweb/static/bulk.css b/bulkweb/static/bulk.css new file mode 100644 index 0000000..67ba25c --- /dev/null +++ b/bulkweb/static/bulk.css @@ -0,0 +1,3 @@ +:root{--bg:#f4f2ed;--surface:#fff;--ink:#171717;--muted:#646464;--line:#d7d3ca;--accent:#262626;--danger:#8f2d1f;--good:#276749;--warn:#8a5a11;--radius:18px;--shadow:0 16px 40px rgba(0,0,0,.07)} +*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.55}a{color:inherit}.site-header,footer{width:min(1180px,calc(100% - 40px));margin:auto;padding:24px 0;display:flex;align-items:center;justify-content:space-between;gap:24px}.site-header nav,footer nav{display:flex;gap:18px;flex-wrap:wrap}.site-header nav a,footer nav a,.brand{text-decoration:none}.brand{font-weight:850;letter-spacing:-.025em}.site-header nav a,footer{color:var(--muted)}main{width:min(1180px,calc(100% - 40px));margin:auto;padding-bottom:80px}.hero{padding:68px 0 78px;max-width:960px}.eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:.74rem;font-weight:850;color:var(--muted)}h1,h2{letter-spacing:-.05em;line-height:1.02;margin:.22em 0}h1{font-size:clamp(3rem,7vw,6.2rem)}h2{font-size:clamp(2rem,4vw,3.5rem)}h3{letter-spacing:-.025em}.lead{font-size:clamp(1.1rem,2vw,1.4rem);max-width:850px;color:#404040}.panel{margin:0 0 24px;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:clamp(24px,5vw,54px);box-shadow:var(--shadow)}.compact-panel h2{font-size:2rem}.section-heading{max-width:820px;margin-bottom:28px}.section-heading>p:last-child{color:var(--muted);font-size:1.03rem}.notice{border-radius:13px;padding:17px 19px;background:#ebe8e1}.privacy-note{margin-top:28px;max-width:900px}.toolbar{display:flex;align-items:end;gap:12px;margin:18px 0}.toolbar.wrap{flex-wrap:wrap}button,.button{font:inherit;border:0;border-radius:999px;background:var(--accent);color:#fff;padding:11px 18px;font-weight:800;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:45px}.button.secondary{background:#fff;color:var(--ink);border:1px solid var(--ink)}.button.subtle{background:#eeeae2;color:var(--ink)}.button.danger,.danger{color:#761f16}.file-button{position:relative;overflow:hidden}.file-button input{position:absolute;inset:0;opacity:0;cursor:pointer}button:disabled{opacity:.5;cursor:not-allowed}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #5b9cff;outline-offset:3px}input,select,textarea{font:inherit;width:100%;border:1px solid var(--line);border-radius:10px;background:#fbfaf7;color:var(--ink);padding:12px 14px}textarea{resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.86rem}.field{display:flex;flex-direction:column;gap:7px;font-weight:750}.field small,.help{color:var(--muted);font-weight:400}.inline-field{display:grid;gap:5px;min-width:min(320px,100%);font-weight:700}.status{margin:18px 0 0;padding:14px 16px;border-left:4px solid #666;background:#f3f1ec;border-radius:10px}.status.good{border-left-color:var(--good);background:#edf7f1}.status.error{border-left-color:var(--danger);background:#fff1ee}.details-box{margin-top:14px;border:1px solid var(--line);border-radius:12px;padding:13px 15px}.details-box summary{cursor:pointer;font-weight:800}.parameter-list{display:grid;grid-template-columns:minmax(180px,.45fr) minmax(0,1fr);margin:15px 0 0}.parameter-list div{display:contents}.parameter-list dt,.parameter-list dd{padding:8px 10px;margin:0;border-top:1px solid var(--line);overflow-wrap:anywhere}.parameter-list dt{font-weight:750}.profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.profile-grid label{display:flex;flex-direction:column;gap:6px;font-weight:700}.wide{grid-column:1/-1}.subsection{border:1px solid var(--line);border-radius:14px;padding:18px;background:#fbfaf7}.subsection h3{font-size:1rem;margin:0 0 13px}.subsection h3 span{font-size:.76rem;font-weight:650;color:var(--muted);margin-left:5px}.profile-grid.nested{margin:0}.url-grid{display:grid;grid-template-columns:minmax(0,1fr) 180px;gap:16px;align-items:end;margin-bottom:16px}.url-grid label{display:flex;flex-direction:column;gap:6px;font-weight:700}.counter-card{min-height:73px;border:1px solid var(--line);border-radius:12px;padding:10px 15px;display:flex;align-items:baseline;justify-content:center;gap:8px;background:#fbfaf7}.counter-card strong{font-size:1.7rem}.counter-card span{color:var(--muted);font-size:.85rem}.messages{display:grid;gap:8px;margin-top:12px}.message{padding:10px 13px;border-radius:9px;background:#fff4e5;border-left:4px solid var(--warn);font-size:.9rem}.message.error{background:#fff0ed;border-left-color:var(--danger)}.message.info{background:#eef4ff;border-left-color:#4f6f9f}.result-table-wrap{overflow:auto;border:1px solid var(--line);border-radius:14px}.result-table{width:100%;border-collapse:collapse;min-width:680px}.result-table th,.result-table td{padding:14px 16px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}.result-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);background:#f7f5f0}.result-table tbody tr:last-child td{border-bottom:0}.result-table code{overflow-wrap:anywhere}.ok{color:var(--good);font-weight:800}.failed{color:var(--danger);font-weight:800}.result-links{display:flex;gap:10px;flex-wrap:wrap}.result-links a{font-size:.86rem;font-weight:750}.output-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-top:22px}.output-grid label{display:flex;flex-direction:column;gap:6px;font-weight:750}.output-grid textarea{min-height:210px}.plain-list{display:grid;gap:8px;padding-left:1.2rem}.help code{background:#eeeae2;padding:2px 5px;border-radius:5px}footer{border-top:1px solid var(--line)} +@media(max-width:760px){.site-header nav{display:none}.profile-grid,.url-grid,.output-grid{grid-template-columns:1fr}.wide{grid-column:auto}.toolbar{align-items:stretch}.toolbar>*{width:100%}.inline-field{min-width:0}.parameter-list{grid-template-columns:1fr}.parameter-list div{display:block;border-top:1px solid var(--line)}.parameter-list dt,.parameter-list dd{border:0;padding:4px 0}footer{align-items:flex-start;flex-direction:column}.hero{padding-top:44px}h1{font-size:3.2rem}} diff --git a/bulkweb/static/bulk.js b/bulkweb/static/bulk.js new file mode 100644 index 0000000..3ae9886 --- /dev/null +++ b/bulkweb/static/bulk.js @@ -0,0 +1,526 @@ +(() => { + 'use strict'; + + const PROFILE_KEY = 'ai-disclosure.bulk.profiles.v1'; + const TEMPLATE_KEY = 'ai-disclosure.bulk.templates.v1'; + const EXPORT_FORMAT = 'ai-disclosure-bulk-browser-data'; + const $ = id => document.getElementById(id); + + const profileFields = [ + ['profile-lang', 'lang'], + ['profile-role', 'legalRole'], + ['profile-use-context', 'useContext'], + ['profile-assurance', 'assurance'], + ['profile-author', 'author'], + ['profile-author-url', 'authorUrl'], + ['profile-responsible-role', 'responsibleRole'], + ['profile-responsible', 'responsible'], + ['profile-responsible-url', 'responsibleUrl'], + ['profile-complaint-name', 'complaintName'], + ['profile-complaint-email', 'complaintEmail'], + ['profile-complaint-url', 'complaintUrl'] + ]; + + let cfg = null; + let activeTemplate = null; + let currentResults = []; + let exportsCache = {}; + + function readStore(key) { + try { + const parsed = JSON.parse(localStorage.getItem(key) || '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (_) { + return {}; + } + } + + function writeStore(key, value) { + localStorage.setItem(key, JSON.stringify(value)); + } + + function setStatus(text, level = '') { + const target = $('template-status'); + target.textContent = text; + target.className = `status ${level}`.trim(); + } + + function addMessage(text, level = '') { + const p = document.createElement('p'); + p.className = `message ${level}`.trim(); + p.textContent = text; + $('url-messages').appendChild(p); + } + + function safeHTTPURL(raw) { + try { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; + return url; + } catch (_) { + return null; + } + } + + function parseTemplate(raw) { + const value = raw.trim(); + if (!value) throw new Error('Bitte zuerst eine Declaration-URL oder einen Query-String einfügen.'); + let params; + const absolute = safeHTTPURL(value); + if (absolute) { + params = new URLSearchParams(absolute.search); + } else { + const query = value.startsWith('?') ? value.slice(1) : value; + params = new URLSearchParams(query); + } + params.delete('subject'); + params.delete('link'); + if ([...params.keys()].length === 0) throw new Error('Die Vorlage enthält keine Parameter.'); + return params; + } + + function activateTemplate(params, label = 'Vorlage geladen') { + activeTemplate = new URLSearchParams(params); + activeTemplate.delete('subject'); + activeTemplate.delete('link'); + $('template-input').value = activeTemplate.toString(); + renderTemplateParams(); + setStatus(`${label}: ${[...activeTemplate.keys()].length} Parameter aktiv.`, 'good'); + } + + function renderTemplateParams() { + const list = $('template-params'); + list.replaceChildren(); + if (!activeTemplate) return; + const entries = [...activeTemplate.entries()].sort(([a], [b]) => a.localeCompare(b)); + for (const [key, value] of entries) { + const row = document.createElement('div'); + const dt = document.createElement('dt'); + const dd = document.createElement('dd'); + dt.textContent = key; + dd.textContent = value; + row.append(dt, dd); + list.appendChild(row); + } + } + + function refreshTemplateSelect(selected = '') { + const templates = readStore(TEMPLATE_KEY); + const select = $('saved-template-select'); + select.replaceChildren(new Option('Gespeicherte Vorlage wählen …', '')); + Object.keys(templates).sort((a, b) => a.localeCompare(b)).forEach(name => select.add(new Option(name, name))); + if (selected && templates[selected]) select.value = selected; + } + + function saveTemplate() { + if (!activeTemplate) { + setStatus('Bitte zuerst eine gültige Vorlage laden.', 'error'); + return; + } + const name = $('template-name').value.trim(); + if (!name) { + setStatus('Bitte einen Namen für die Vorlage angeben.', 'error'); + $('template-name').focus(); + return; + } + const templates = readStore(TEMPLATE_KEY); + templates[name] = {query: activeTemplate.toString(), updatedAt: new Date().toISOString()}; + writeStore(TEMPLATE_KEY, templates); + refreshTemplateSelect(name); + setStatus(`Vorlage „${name}“ wurde nur in diesem Browser gespeichert.`, 'good'); + } + + function loadSavedTemplate(name) { + if (!name) return; + const item = readStore(TEMPLATE_KEY)[name]; + if (!item || typeof item.query !== 'string') return; + $('template-name').value = name; + activateTemplate(new URLSearchParams(item.query), `Vorlage „${name}“ geladen`); + } + + function deleteTemplate() { + const name = $('saved-template-select').value; + if (!name) return; + const templates = readStore(TEMPLATE_KEY); + delete templates[name]; + writeStore(TEMPLATE_KEY, templates); + refreshTemplateSelect(); + if ($('template-name').value === name) $('template-name').value = ''; + setStatus(`Vorlage „${name}“ wurde aus diesem Browser gelöscht.`); + } + + function emptyProfileForm() { + $('profile-name').value = ''; + for (const [id] of profileFields) $(id).value = ''; + $('profile-select').value = ''; + } + + function profileFromForm() { + const profile = {}; + for (const [id, key] of profileFields) { + const value = $(id).value.trim(); + if (value) profile[key] = value; + } + return profile; + } + + function validateProfile(profile) { + const urlKeys = ['authorUrl', 'responsibleUrl', 'complaintUrl']; + for (const key of urlKeys) { + if (profile[key] && !safeHTTPURL(profile[key])) return `${key} muss eine absolute http(s)-URL ohne Zugangsdaten sein.`; + } + if (profile.complaintEmail && !$('profile-complaint-email').checkValidity()) return 'Die E-Mail-Adresse der Rückmeldestelle ist nicht gültig.'; + return ''; + } + + function validateMergedParams(params) { + if (params.get('authorUrl') && !params.get('author')) return 'Zu einer Autoren-URL muss auch ein Autor bzw. eine Autorin angegeben werden.'; + const responsibilityStarted = params.get('responsibleRole') || params.get('responsible') || params.get('responsibleUrl'); + if (responsibilityStarted && (!params.get('responsibleRole') || !params.get('responsible'))) return 'Bei redaktioneller Verantwortung sind Rolle und Name/Organisation gemeinsam erforderlich.'; + const complaintStarted = params.get('complaintName') || params.get('complaintEmail') || params.get('complaintUrl'); + if (complaintStarted && (!params.get('complaintName') || (!params.get('complaintEmail') && !params.get('complaintUrl')))) return 'Die Rückmeldestelle benötigt einen Namen und mindestens E-Mail oder Kontakt-URL.'; + return ''; + } + + function refreshProfileSelect(selected = '') { + const profiles = readStore(PROFILE_KEY); + const select = $('profile-select'); + select.replaceChildren(new Option('Website-Profil wählen …', '')); + Object.keys(profiles).sort((a, b) => a.localeCompare(b)).forEach(name => select.add(new Option(name, name))); + if (selected && profiles[selected]) select.value = selected; + } + + function saveProfile() { + const name = $('profile-name').value.trim(); + if (!name) { + alert('Bitte einen Profilnamen angeben.'); + $('profile-name').focus(); + return; + } + const profile = profileFromForm(); + const error = validateProfile(profile); + if (error) { + alert(error); + return; + } + const profiles = readStore(PROFILE_KEY); + profiles[name] = {values: profile, updatedAt: new Date().toISOString()}; + writeStore(PROFILE_KEY, profiles); + refreshProfileSelect(name); + } + + function loadProfile(name) { + if (!name) { + emptyProfileForm(); + return; + } + const item = readStore(PROFILE_KEY)[name]; + if (!item || !item.values) return; + $('profile-name').value = name; + for (const [id, key] of profileFields) $(id).value = item.values[key] || ''; + } + + function deleteProfile() { + const name = $('profile-select').value; + if (!name) return; + const profiles = readStore(PROFILE_KEY); + delete profiles[name]; + writeStore(PROFILE_KEY, profiles); + refreshProfileSelect(); + emptyProfileForm(); + } + + function activeQuery() { + if (!activeTemplate) throw new Error('Bitte zuerst eine Kennzeichnungsvorlage laden.'); + const params = new URLSearchParams(activeTemplate); + params.delete('subject'); + params.delete('link'); + const profile = profileFromForm(); + const error = validateProfile(profile); + if (error) throw new Error(error); + for (const [key, value] of Object.entries(profile)) params.set(key, value); + const mergedError = validateMergedParams(params); + if (mergedError) throw new Error(mergedError); + return params; + } + + function csvCellLine(line) { + let value = line.trim(); + if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { + value = value.slice(1, -1).replaceAll('""', '"'); + } + return value; + } + + function parseSubjects() { + $('url-messages').replaceChildren(); + let lines = $('url-input').value.split(/\r?\n/).map(csvCellLine).filter(Boolean); + if (lines[0]?.toLowerCase() === 'url') lines = lines.slice(1); + const baseRaw = $('base-url').value.trim(); + let base = null; + if (baseRaw) { + base = safeHTTPURL(baseRaw); + if (!base) addMessage('Die Basis-URL ist ungültig und wird nicht verwendet.', 'error'); + } + + const unique = new Map(); + const invalid = []; + let duplicates = 0; + for (const raw of lines) { + let parsed = safeHTTPURL(raw); + if (!parsed && base) { + try { + const candidate = new URL(raw, base); + if (['http:', 'https:'].includes(candidate.protocol) && !candidate.username && !candidate.password) parsed = candidate; + } catch (_) {} + } + if (!parsed) { + invalid.push(raw); + continue; + } + const normalized = parsed.href; + if (unique.has(normalized)) { + duplicates++; + continue; + } + unique.set(normalized, normalized); + } + const subjects = [...unique.values()]; + $('url-counter').querySelector('strong').textContent = String(subjects.length); + if (duplicates) addMessage(`${duplicates} Duplikat${duplicates === 1 ? '' : 'e'} entfernt.`, 'info'); + if (invalid.length) addMessage(`${invalid.length} ungültige Zeile${invalid.length === 1 ? '' : 'n'} ignoriert: ${invalid.slice(0, 3).join(', ')}${invalid.length > 3 ? ' …' : ''}`, 'error'); + if (cfg && subjects.length > cfg.maxURLs) addMessage(`Maximal ${cfg.maxURLs} URLs pro Lauf. Bitte die Liste aufteilen.`, 'error'); + return subjects; + } + + function csvEscape(value) { + const text = String(value ?? ''); + return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; + } + + function buildExports(results) { + const successful = results.filter(item => item.ok && item.data); + const html = successful.map(item => `\n${item.data.html}`).join('\n\n'); + const markdown = successful.map(item => `### ${item.subject}\n\n${item.data.markdown}`).join('\n\n'); + const jsonArray = successful.map(item => item.data.jsonLd); + const json = JSON.stringify(jsonArray, null, 2); + const jsonl = successful.map(item => JSON.stringify(item.data.jsonLd)).join('\n'); + const csvRows = [['subject', 'declaration', 'manifest', 'badge']]; + successful.forEach(item => csvRows.push([item.subject, item.data.declarationUrl, item.data.manifestUrl, item.data.badgeUrl])); + const csv = csvRows.map(row => row.map(csvEscape).join(',')).join('\n'); + return {html, markdown, json, jsonl, csv}; + } + + function renderResults(results) { + currentResults = results; + exportsCache = buildExports(results); + const successful = results.filter(item => item.ok).length; + const failed = results.length - successful; + $('results-section').hidden = false; + $('result-summary').textContent = `${successful} Kennzeichnung${successful === 1 ? '' : 'en'} erzeugt${failed ? `, ${failed} Fehler` : ''}.`; + const tbody = $('result-rows'); + tbody.replaceChildren(); + results.forEach(item => { + const tr = document.createElement('tr'); + const subjectCell = document.createElement('td'); + const code = document.createElement('code'); + code.textContent = item.subject; + subjectCell.appendChild(code); + const statusCell = document.createElement('td'); + statusCell.className = item.ok ? 'ok' : 'failed'; + statusCell.textContent = item.ok ? '✓ erzeugt' : `Fehler: ${item.error || 'unbekannt'}`; + const linksCell = document.createElement('td'); + if (item.ok && item.data) { + const links = document.createElement('div'); + links.className = 'result-links'; + [['Declaration', item.data.declarationUrl], ['JSON-LD', item.data.manifestUrl], ['Badge', item.data.badgeUrl]].forEach(([label, href]) => { + const a = document.createElement('a'); + a.textContent = label; + a.href = href; + a.target = '_blank'; + a.rel = 'noopener'; + links.appendChild(a); + }); + linksCell.appendChild(links); + } + tr.append(subjectCell, statusCell, linksCell); + tbody.appendChild(tr); + }); + $('output-html').value = exportsCache.html; + $('output-markdown').value = exportsCache.markdown; + $('output-json').value = exportsCache.json; + $('output-csv').value = exportsCache.csv; + $('results-section').scrollIntoView({behavior: 'smooth', block: 'start'}); + } + + async function generate() { + let query; + try { + query = activeQuery(); + } catch (error) { + setStatus(error.message, 'error'); + return; + } + const subjects = parseSubjects(); + if (!subjects.length) { + addMessage('Bitte mindestens eine gültige URL einfügen.', 'error'); + return; + } + if (subjects.length > cfg.maxURLs) return; + + const button = $('generate'); + const original = button.textContent; + button.disabled = true; + button.textContent = `${subjects.length} Kennzeichnungen werden erzeugt …`; + try { + const response = await fetch('/api/render-batch', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'Accept': 'application/json'}, + body: JSON.stringify({template: query.toString(), subjects}) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.detail || `HTTP ${response.status}`); + renderResults(payload.results || []); + } catch (error) { + addMessage(`Bulk-Lauf fehlgeschlagen: ${error.message}`, 'error'); + } finally { + button.disabled = false; + button.textContent = original; + } + } + + async function copyExport(kind, button) { + const value = exportsCache[kind] || ''; + if (!value) return; + await navigator.clipboard.writeText(value); + const old = button.textContent; + button.textContent = 'Kopiert'; + setTimeout(() => { button.textContent = old; }, 1300); + } + + function downloadExport(kind) { + const spec = { + html: ['ai-disclosure-bulk.html', 'text/html;charset=utf-8'], + markdown: ['ai-disclosure-bulk.md', 'text/markdown;charset=utf-8'], + json: ['ai-disclosure-bulk.json', 'application/ld+json;charset=utf-8'], + jsonl: ['ai-disclosure-bulk.jsonl', 'application/x-ndjson;charset=utf-8'], + csv: ['ai-disclosure-bulk.csv', 'text/csv;charset=utf-8'] + }[kind]; + const value = exportsCache[kind] || ''; + if (!spec || !value) return; + downloadBlob(spec[0], value, spec[1]); + } + + function downloadBlob(filename, content, type) { + const blob = new Blob([content], {type}); + const href = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = href; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(href), 500); + } + + function exportBrowserData() { + const payload = { + format: EXPORT_FORMAT, + version: 1, + exportedAt: new Date().toISOString(), + profiles: readStore(PROFILE_KEY), + templates: readStore(TEMPLATE_KEY) + }; + downloadBlob('ai-disclosure-bulk-browser-data.json', JSON.stringify(payload, null, 2), 'application/json;charset=utf-8'); + } + + async function importBrowserData(file) { + if (!file) return; + try { + const payload = JSON.parse(await file.text()); + if (payload.format !== EXPORT_FORMAT || payload.version !== 1) throw new Error('Unbekanntes Exportformat.'); + if (!payload.profiles || typeof payload.profiles !== 'object' || !payload.templates || typeof payload.templates !== 'object') throw new Error('Export ist unvollständig.'); + writeStore(PROFILE_KEY, payload.profiles); + writeStore(TEMPLATE_KEY, payload.templates); + refreshProfileSelect(); + refreshTemplateSelect(); + alert('Profile und Vorlagen wurden importiert.'); + } catch (error) { + alert(`Import fehlgeschlagen: ${error.message}`); + } finally { + $('import-browser-data').value = ''; + } + } + + function importFragmentTemplate() { + if (!location.hash.startsWith('#')) return; + const hash = new URLSearchParams(location.hash.slice(1)); + const raw = hash.get('template'); + if (!raw) return; + try { + const payload = JSON.parse(raw); + if (payload.version !== 1 || typeof payload.query !== 'string') throw new Error('Unbekanntes Vorlagenformat.'); + const params = new URLSearchParams(payload.query); + if (payload.theme) params.set('theme', payload.theme); + activateTemplate(params, 'Aus dem Generator übernommen'); + $('template-name').value = payload.name || ''; + } catch (error) { + setStatus(`Vorlage aus dem Generator konnte nicht übernommen werden: ${error.message}`, 'error'); + } finally { + history.replaceState(null, '', location.pathname + location.search); + } + } + + function clearBrowserData() { + if (!confirm('Alle lokal gespeicherten Website-Profile und Kennzeichnungsvorlagen in diesem Browser löschen?')) return; + localStorage.removeItem(PROFILE_KEY); + localStorage.removeItem(TEMPLATE_KEY); + refreshProfileSelect(); + refreshTemplateSelect(); + emptyProfileForm(); + setStatus('Lokale Profile und Vorlagen wurden gelöscht.'); + } + + async function init() { + try { + const response = await fetch('/api/config', {headers: {'Accept': 'application/json'}}); + cfg = await response.json(); + } catch (_) { + cfg = {maxURLs: 500}; + } + refreshTemplateSelect(); + refreshProfileSelect(); + importFragmentTemplate(); + parseSubjects(); + + $('load-template').addEventListener('click', () => { + try { + activateTemplate(parseTemplate($('template-input').value)); + } catch (error) { + setStatus(error.message, 'error'); + } + }); + $('save-template').addEventListener('click', saveTemplate); + $('saved-template-select').addEventListener('change', event => loadSavedTemplate(event.target.value)); + $('delete-template').addEventListener('click', deleteTemplate); + $('profile-select').addEventListener('change', event => loadProfile(event.target.value)); + $('new-profile').addEventListener('click', emptyProfileForm); + $('save-profile').addEventListener('click', saveProfile); + $('delete-profile').addEventListener('click', deleteProfile); + $('export-browser-data').addEventListener('click', exportBrowserData); + $('import-browser-data').addEventListener('change', event => importBrowserData(event.target.files?.[0])); + $('clear-browser-data').addEventListener('click', clearBrowserData); + $('url-input').addEventListener('input', parseSubjects); + $('base-url').addEventListener('input', parseSubjects); + $('clear-urls').addEventListener('click', () => { + $('url-input').value = ''; + $('url-messages').replaceChildren(); + parseSubjects(); + }); + $('generate').addEventListener('click', generate); + document.querySelectorAll('[data-copy]').forEach(button => button.addEventListener('click', () => copyExport(button.dataset.copy, button))); + document.querySelectorAll('[data-download]').forEach(button => button.addEventListener('click', () => downloadExport(button.dataset.download))); + } + + init(); +})(); diff --git a/bulkweb/templates/index.html b/bulkweb/templates/index.html new file mode 100644 index 0000000..48052cf --- /dev/null +++ b/bulkweb/templates/index.html @@ -0,0 +1,247 @@ +{{define "index.html"}} + + + + + + {{.Name}} + + + + + + +
+
+

Bulk Generator

+

Einmal definieren.
Viele Inhalte kennzeichnen.

+

Übernimm eine Kennzeichnung aus dem normalen Generator, ergänze wiederkehrende Website-Daten und erzeuge HTML, Markdown und JSON-LD für bis zu {{.MaxURLs}} Inhalts-URLs in einem Lauf.

+
Datensparsam: Inhalts-URLs werden nicht abgerufen. Website-Profile und gespeicherte Vorlagen bleiben ausschließlich im Browser-Speicher dieses Geräts. URL-Listen und Ergebnisse werden nicht dauerhaft gespeichert.
+
+ +
+
+

1 · Kennzeichnung

+

Vorlage festlegen

+

Am sichersten ist die Übernahme direkt aus dem bestehenden Generator. Alternativ kannst du eine Declaration-URL oder deren Query-String einfügen.

+
+ +
+ Kennzeichnung im Generator definieren + + +
+ + + +
+ + + +
+ +
Noch keine Vorlage geladen.
+
+ Aktive Parameter anzeigen +
+
+
+ +
+
+

2 · Wiederkehrende Daten

+

Website-Profil

+

Hier gehören Angaben hinein, die du nicht bei jedem Batch erneut eingeben möchtest. Nicht ausgefüllte Profilfelder verändern die geladene Kennzeichnungsvorlage nicht.

+
+ +
+ + + +
+ +
+ + + + + + +
+

Autor / Byline optional

+
+ + +
+
+ +
+

Redaktionelle Verantwortung nur eintragen, wenn sie zur Vorlage passt

+
+ + + +
+
+ +
+

Beschwerde- / Rückmeldestelle Best Practice

+
+ + + +
+
+
+ +
+ + + + +
+

Gespeichert werden nur die von dir angelegten Profile und Vorlagen in localStorage. Eine Synchronisierung zum Server findet nicht statt.

+
+ +
+
+

3 · Inhalte

+

URLs oder Pfade einfügen

+

Eine URL bzw. ein Pfad pro Zeile. Bei relativen Pfaden wird die optionale Basis-URL verwendet. Eine einspaltige CSV-Datei mit der Überschrift url kann ebenfalls direkt eingefügt werden.

+
+
+ +
0gültige URLs
+
+ +
+
+ + +
+
+ + + +
+

Was dieser Container bewusst nicht tut

+ +
+
+ + + + + +{{end}} diff --git a/cmd/bulk/main.go b/cmd/bulk/main.go new file mode 100644 index 0000000..b0e5765 --- /dev/null +++ b/cmd/bulk/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/b1tsblog/ai-disclosure-standard/internal/bulk" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "--healthcheck" { + url := os.Getenv("BULK_HEALTHCHECK_URL") + if url == "" { + url = "http://127.0.0.1:8081/healthz" + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } + _ = resp.Body.Close() + return + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + cfg := bulk.ConfigFromEnv() + handler, err := bulk.New(cfg, logger) + if err != nil { + logger.Error("bulk application initialization failed", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + server := &http.Server{ + Addr: cfg.ListenAddress, Handler: handler, + ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, + WriteTimeout: 90 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20, + } + + go func() { + logger.Info("bulk server started", "address", cfg.ListenAddress, "core", cfg.CoreInternalURL) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("bulk server failed", "error", err) + os.Exit(1) + } + }() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Error("bulk graceful shutdown failed", "error", err) + os.Exit(1) + } +} diff --git a/compose.yaml b/compose.yaml index ca2a620..ba81798 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,8 +1,13 @@ services: app: - image: git.send.nrw/sendnrw/ai-disclosure-standard:latest + build: + context: . + dockerfile: Dockerfile + image: ai-disclosure-standard:1.8.0-local env_file: - .env + environment: + BULK_URL: ${BULK_URL:-http://localhost:8081} ports: - "8080:8080" volumes: @@ -22,5 +27,37 @@ services: retries: 3 start_period: 5s + bulk: + build: + context: . + dockerfile: Dockerfile.bulk + image: ai-disclosure-bulk:1.0.0-local + environment: + CORE_INTERNAL_URL: http://app:8080 + DISCLOSURE_BASE_URL: ${BASE_URL:-http://localhost:8080} + GENERATOR_URL: ${BASE_URL:-http://localhost:8080} + BULK_PUBLIC_NAME: AI Disclosure Bulk + BULK_MAX_URLS: ${BULK_MAX_URLS:-500} + BULK_WORKERS: ${BULK_WORKERS:-4} + ports: + - "8081:8081" + read_only: true + tmpfs: + - /tmp:size=8m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + depends_on: + app: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "/ai-disclosure-bulk", "--healthcheck"] + interval: 15s + timeout: 3s + retries: 3 + start_period: 5s + volumes: license-cache: diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml index ae9c36d..70def9e 100644 --- a/deploy/kubernetes.yaml +++ b/deploy/kubernetes.yaml @@ -25,7 +25,7 @@ spec: type: RuntimeDefault containers: - name: app - image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.7.0 + image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.0 imagePullPolicy: IfNotPresent ports: - name: http diff --git a/deploy/swarm-stack.yaml b/deploy/swarm-stack.yaml index 7c684d1..79fd1b2 100644 --- a/deploy/swarm-stack.yaml +++ b/deploy/swarm-stack.yaml @@ -1,7 +1,7 @@ version: "3.9" services: app: - image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.7.0 + image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.0 environment: BASE_URL: https://ai.example.org PUBLIC_NAME: AI Usage Disclosure diff --git a/docs/BACKGROUND-PAGE.md b/docs/BACKGROUND-PAGE.md index aafb229..37d72df 100644 --- a/docs/BACKGROUND-PAGE.md +++ b/docs/BACKGROUND-PAGE.md @@ -1,6 +1,6 @@ # Mehrsprachige Hintergrundseite -Version 1.7.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit. +Version 1.8.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit. ## Routen diff --git a/docs/LICENSE-CLIENT.md b/docs/LICENSE-CLIENT.md index 35dd6c9..e3d53fd 100644 --- a/docs/LICENSE-CLIENT.md +++ b/docs/LICENSE-CLIENT.md @@ -16,7 +16,7 @@ Initialization is performed in `internal/app/server.go`. The product ID and embe ```go licenses := licenseclient.New(ctx, licenseclient.Config{ Product: "ai-disclosure-standard", - ClientVersion: "1.7.0", + ClientVersion: "1.8.0", Token: cfg.LicenseToken, TrustStore: trustStore, BaseURL: cfg.BaseURL, diff --git a/docs/LICENSE-INTEGRATION.md b/docs/LICENSE-INTEGRATION.md index 90ef2cf..104abbb 100644 --- a/docs/LICENSE-INTEGRATION.md +++ b/docs/LICENSE-INTEGRATION.md @@ -119,7 +119,7 @@ Anfrage: "baseUrl": "https://ai.example.org", "host": "ai.example.org", "instanceId": "production-eu-1", - "clientVersion": "1.7.0" + "clientVersion": "1.8.0" } ``` diff --git a/internal/app/config.go b/internal/app/config.go index 5ab6d5c..51a2fa1 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -21,6 +21,7 @@ type Config struct { PublicName string ContactURL string SalesURL string + BulkURL string DefaultLanguage string TrustProxy bool TrustedProxies []netip.Prefix @@ -89,6 +90,7 @@ func ConfigFromEnv() Config { PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"), ContactURL: contactURL, SalesURL: env("SALES_URL", contactURL), + BulkURL: strings.TrimRight(env("BULK_URL", ""), "/"), DefaultLanguage: env("DEFAULT_LANGUAGE", "de"), TrustProxy: trustProxy, TrustedProxies: trustedProxies, @@ -159,6 +161,7 @@ func validateConfig(cfg Config) error { }{ {"CONTACT_URL", cfg.ContactURL}, {"SALES_URL", cfg.SalesURL}, + {"BULK_URL", cfg.BulkURL}, {"SUPERVISORY_AUTHORITY_URL", cfg.SupervisoryAuthorityURL}, {"CONSUMER_DISPUTE_URL", cfg.ConsumerDisputeURL}, } { diff --git a/internal/app/legal.go b/internal/app/legal.go index df539ae..1529b39 100644 --- a/internal/app/legal.go +++ b/internal/app/legal.go @@ -163,7 +163,7 @@ func privacyPage(cfg Config, lang string) legalPage { { Title: "4. Cookies, Tracking und lokale Speicherung", Paragraphs: []string{ - "Die mitgelieferte Weboberfläche setzt keine Cookies, verwendet kein Webtracking und speichert keine Daten in Local Storage oder Session Storage. Wird die Anwendung um Analyse-, Marketing-, Schrift-, Karten-, Video- oder andere Drittinhalte erweitert, muss die Datenschutzerklärung angepasst und eine gegebenenfalls erforderliche Einwilligung vor dem Zugriff auf das Endgerät eingeholt werden.", + "Die Kern-Weboberfläche setzt keine Cookies, verwendet kein Webtracking und speichert keine Daten in Local Storage oder Session Storage. Der optionale Bulk-Container speichert vom Nutzer angelegte Website-Profile und Kennzeichnungsvorlagen ausschließlich lokal im Local Storage des jeweiligen Browsers; URL-Listen und erzeugte Ergebnisse werden dort nicht dauerhaft gespeichert und nicht zum Server synchronisiert. Lokale Bulk-Daten können exportiert, importiert oder vollständig gelöscht werden. Wird die Anwendung um Analyse-, Marketing-, Schrift-, Karten-, Video- oder andere Drittinhalte erweitert, muss die Datenschutzerklärung angepasst und eine gegebenenfalls erforderliche Einwilligung vor dem Zugriff auf das Endgerät eingeholt werden.", }, }, { @@ -208,7 +208,7 @@ func privacyPage(cfg Config, lang string) legalPage { "The generator is stateless and does not store inputs in an application database. Inputs are nevertheless processed as URL parameters and may appear in browser history and proxy or access logs.", "Generated declaration and JSON-LD URLs are designed for public embedding. Do not enter confidential data, special-category personal data or unnecessary personal information.", }}, - {Title: "4. Cookies and tracking", Paragraphs: []string{"The bundled interface sets no cookies, uses no web tracking and does not store data in Local Storage or Session Storage. Operators adding analytics, marketing or third-party embeds must update this notice and obtain any legally required consent before accessing the user's device."}}, + {Title: "4. Cookies and tracking", Paragraphs: []string{"The core interface sets no cookies, uses no web tracking and does not store data in Local Storage or Session Storage. The optional bulk container stores user-created site profiles and disclosure templates only in the respective browser's Local Storage; URL lists and generated results are not persistently stored there and are not synchronised to the server. Local bulk data can be exported, imported or deleted completely. Operators adding analytics, marketing or third-party embeds must update this notice and obtain any legally required consent before accessing the user's device."}}, {Title: "5. Hosting and recipients", Fields: compactFields([]legalField{{Label: "Hosting provider", Value: requiredValue(cfg.HostingProvider)}, {Label: "Address / region", Value: cfg.HostingAddress}, {Label: "Other recipients", Value: cfg.DataRecipients}, {Label: "Third-country transfers", Value: cfg.ThirdCountryTransfers}})}, {Title: "6. Optional licence validation", Paragraphs: []string{"Offline mode performs no online licence validation. Hybrid and online modes send the licence token, product identifier, public base URL, host, optional instance ID and client version to the configured licence server. The operator must document that service separately."}}, {Title: "7. Data-subject rights", Paragraphs: []string{"Subject to the GDPR, individuals may have rights of access, rectification, erasure, restriction, portability, objection and withdrawal of consent, as well as the right to complain to a supervisory authority."}, Fields: compactFields([]legalField{{Label: "Supervisory authority", Value: cfg.SupervisoryAuthorityName, URL: cfg.SupervisoryAuthorityURL}})}, diff --git a/internal/app/licensing.go b/internal/app/licensing.go index 337a87a..5ffcbca 100644 --- a/internal/app/licensing.go +++ b/internal/app/licensing.go @@ -9,7 +9,7 @@ import ( const ( ProductID = "ai-disclosure-standard" - ProductVersion = "1.7.0" + ProductVersion = "1.8.0" FeatureCustomText = "custom_text" FeatureCustomBadge = "custom_badge" FeatureWhiteLabel = "white_label" diff --git a/internal/app/server.go b/internal/app/server.go index 64de257..9361e22 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "html" "html/template" "io" "io/fs" @@ -92,6 +93,7 @@ type clientConfig struct { SelectedLanguage string `json:"selectedLanguage"` Locales map[string]i18n.Locale `json:"locales"` Capabilities map[string]bool `json:"capabilities"` + BulkURL string `json:"bulkURL,omitempty"` } type publicLicenseStatus struct { @@ -141,6 +143,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /v1/badge.svg", s.handleBadge) s.mux.HandleFunc("GET /declaration", s.handleDeclaration) s.mux.HandleFunc("GET /v1/declaration.json", s.handleManifest) + s.mux.HandleFunc("GET /v1/render", s.handleRender) s.mux.HandleFunc("POST /v1/validate", s.handleValidate) s.mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities) s.mux.HandleFunc("GET /schema/v1/declaration.schema.json", s.handleSchema) @@ -161,6 +164,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { locale := i18n.Get(lang) cfg := clientConfig{ BaseURL: s.cfg.BaseURL, SelectedLanguage: lang, Locales: i18n.ClientCatalogs(), + BulkURL: s.cfg.BulkURL, Capabilities: map[string]bool{ FeatureCustomText: s.licenses.Has(FeatureCustomText), FeatureCustomBadge: s.licenses.Has(FeatureCustomBadge), @@ -458,6 +462,93 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(d) } +type renderedArtifacts struct { + Subject string `json:"subject,omitempty"` + DeclarationURL string `json:"declarationUrl"` + BadgeURL string `json:"badgeUrl"` + ManifestURL string `json:"manifestUrl"` + HTML string `json:"html"` + Markdown string `json:"markdown"` + JSONLD declaration.Declaration `json:"jsonLd"` +} + +func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) { + q := cloneValues(r.URL.Query()) + theme := strings.TrimSpace(q.Get("theme")) + q.Del("theme") + if theme == "" { + theme = "mono" + } + if theme != "mono" && theme != "color" && theme != "emoji" { + s.problem(w, http.StatusBadRequest, "invalid_theme", "theme must be one of mono, color or emoji") + return + } + q.Set("lang", s.languageFromValues(r, q)) + d, err := s.declarationFromQuery(q) + if err != nil { + s.declarationError(w, err) + return + } + + locale := i18n.Get(d.Language) + declarationURL := s.cfg.BaseURL + "/declaration?" + q.Encode() + manifestURL := s.cfg.BaseURL + "/v1/declaration.json?" + q.Encode() + badgeQ := cloneValues(q) + badgeQ.Set("theme", theme) + badgeQ.Set("link", declarationURL) + badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode() + + assessment := assessLegalContext(d, locale) + alt := renderBadgeAlt(q, d, locale, assessment) + imageMarkup := fmt.Sprintf(`%s`, html.EscapeString(declarationURL), html.EscapeString(badgeURL), html.EscapeString(alt)) + htmlMarkup := imageMarkup + markdown := fmt.Sprintf(`[![%s](%s)](%s)`, markdownAlt(alt), badgeURL, declarationURL) + if assessment.RequiresDisclosure && theme == "emoji" { + htmlMarkup += " " + html.EscapeString(assessment.BadgeMessage) + "" + markdown += " **" + markdownText(assessment.BadgeMessage) + "**" + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(renderedArtifacts{ + Subject: d.Subject, DeclarationURL: declarationURL, BadgeURL: badgeURL, ManifestURL: manifestURL, + HTML: htmlMarkup, Markdown: markdown, JSONLD: d, + }) +} + +func renderBadgeAlt(q url.Values, d declaration.Declaration, locale i18n.Locale, assessment legalAssessment) string { + if assessment.RequiresDisclosure && assessment.BadgeMessage != "" { + return assessment.BadgeMessage + } + if q.Get("mode") == "article" || len(d.Components) > 1 { + if value := locale.Text["badge_article"]; value != "" { + return value + } + } + if preset, ok := locale.Presets[q.Get("preset")]; ok && preset.Title != "" { + return preset.Title + } + for _, component := range d.Components { + if value := locale.Extents[component.AIExtent]; value != "" { + return value + } + } + return "AI usage disclosure" +} + +func markdownAlt(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `]`, `\]`) + return strings.ReplaceAll(value, `[`, `\[`) +} + +func markdownText(value string) string { + for _, marker := range []string{"\\", "*", "_", "`", "[", "]"} { + value = strings.ReplaceAll(value, marker, "\\"+marker) + } + return value +} + func (s *Server) declarationFromQuery(q url.Values) (declaration.Declaration, error) { return declaration.NewFromQueryWithOptions(q, s.cfg.BaseURL+"/context/v1", declaration.ParseOptions{ AllowCustomText: s.licenses.Has(FeatureCustomText), AllowCustomBadge: s.licenses.Has(FeatureCustomBadge), DefaultLanguage: s.cfg.DefaultLanguage, diff --git a/internal/app/server_test.go b/internal/app/server_test.go index cbe2bfd..c12ea21 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -686,3 +686,55 @@ func TestUnclearDeepfakeAssessmentUsesCautiousDisclosureGuidance(t *testing.T) { } } } + +func TestRenderEndpointReturnsReusableArtifacts(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/render?component=text&extent=partial&review=editorial&lang=de&subject=https%3A%2F%2Fcontent.example%2Farticle&theme=mono", nil) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var payload struct { + Subject string `json:"subject"` + DeclarationURL string `json:"declarationUrl"` + BadgeURL string `json:"badgeUrl"` + ManifestURL string `json:"manifestUrl"` + HTML string `json:"html"` + Markdown string `json:"markdown"` + JSONLD map[string]any `json:"jsonLd"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Subject != "https://content.example/article" { + t.Fatalf("subject %q", payload.Subject) + } + for name, value := range map[string]string{"declaration": payload.DeclarationURL, "badge": payload.BadgeURL, "manifest": payload.ManifestURL, "html": payload.HTML, "markdown": payload.Markdown} { + if value == "" { + t.Fatalf("%s is empty", name) + } + } + if got, _ := payload.JSONLD["subject"].(string); got != payload.Subject { + t.Fatalf("jsonLd subject %q", got) + } + if strings.Contains(payload.DeclarationURL, "theme=") { + t.Fatalf("theme leaked into declaration URL: %s", payload.DeclarationURL) + } + if !strings.Contains(payload.BadgeURL, "theme=mono") { + t.Fatalf("badge URL missing theme: %s", payload.BadgeURL) + } +} + +func TestGeneratorExposesBulkHandoffWhenConfigured(t *testing.T) { + h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", BulkURL: "https://bulk.example.org", PublicName: "Test", DefaultLanguage: "de"}) + r := httptest.NewRequest(http.MethodGet, "/?lang=de", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `id="bulk-template-link"`) || !strings.Contains(body, `"bulkURL":"https://bulk.example.org"`) { + t.Fatalf("bulk handoff missing: %s", body) + } +} diff --git a/internal/bulk/config.go b/internal/bulk/config.go new file mode 100644 index 0000000..9d48e8d --- /dev/null +++ b/internal/bulk/config.go @@ -0,0 +1,98 @@ +package bulk + +import ( + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + ListenAddress string + CoreInternalURL string + DisclosureBaseURL string + GeneratorURL string + PublicName string + MaxURLs int + Workers int + RequestTimeout time.Duration +} + +func ConfigFromEnv() Config { + return Config{ + ListenAddress: env("BULK_LISTEN_ADDRESS", ":8081"), + CoreInternalURL: strings.TrimRight(env("CORE_INTERNAL_URL", "http://app:8080"), "/"), + DisclosureBaseURL: strings.TrimRight(env("DISCLOSURE_BASE_URL", "http://localhost:8080"), "/"), + GeneratorURL: strings.TrimRight(env("GENERATOR_URL", env("DISCLOSURE_BASE_URL", "http://localhost:8080")), "/"), + PublicName: env("BULK_PUBLIC_NAME", "AI Disclosure Bulk"), + MaxURLs: intEnv("BULK_MAX_URLS", 500), + Workers: intEnv("BULK_WORKERS", 4), + RequestTimeout: durationEnv("BULK_REQUEST_TIMEOUT", 8*time.Second), + } +} + +func ValidateConfig(cfg Config) error { + for name, value := range map[string]string{ + "CORE_INTERNAL_URL": cfg.CoreInternalURL, + "DISCLOSURE_BASE_URL": cfg.DisclosureBaseURL, + "GENERATOR_URL": cfg.GeneratorURL, + } { + if err := validateOrigin(name, value); err != nil { + return err + } + } + if strings.TrimSpace(cfg.PublicName) == "" { + return fmt.Errorf("BULK_PUBLIC_NAME must not be empty") + } + if cfg.MaxURLs < 1 || cfg.MaxURLs > 5000 { + return fmt.Errorf("BULK_MAX_URLS must be between 1 and 5000") + } + if cfg.Workers < 1 || cfg.Workers > 32 { + return fmt.Errorf("BULK_WORKERS must be between 1 and 32") + } + if cfg.RequestTimeout < time.Second || cfg.RequestTimeout > 60*time.Second { + return fmt.Errorf("BULK_REQUEST_TIMEOUT must be between 1s and 60s") + } + return nil +} + +func validateOrigin(name, value string) error { + u, err := url.Parse(value) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return fmt.Errorf("%s must be an absolute http(s) origin without path, query, credentials or fragment", name) + } + return nil +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func intEnv(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + +func durationEnv(key string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/internal/bulk/server.go b/internal/bulk/server.go new file mode 100644 index 0000000..2687e04 --- /dev/null +++ b/internal/bulk/server.go @@ -0,0 +1,279 @@ +package bulk + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "io/fs" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + bulkweb "github.com/b1tsblog/ai-disclosure-standard/bulkweb" +) + +const Version = "1.0.0" + +type Server struct { + cfg Config + logger *slog.Logger + templates *template.Template + client *http.Client + mux *http.ServeMux +} + +type pageData struct { + Name string + Version string + DisclosureBaseURL string + GeneratorURL string + MaxURLs int +} + +type publicConfig struct { + DisclosureBaseURL string `json:"disclosureBaseURL"` + GeneratorURL string `json:"generatorURL"` + MaxURLs int `json:"maxURLs"` + Version string `json:"version"` +} + +type batchRequest struct { + Template string `json:"template"` + Subjects []string `json:"subjects"` +} + +type batchResult struct { + Subject string `json:"subject"` + OK bool `json:"ok"` + Data json.RawMessage `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +type batchResponse struct { + Results []batchResult `json:"results"` +} + +func New(cfg Config, logger *slog.Logger) (http.Handler, error) { + if err := ValidateConfig(cfg); err != nil { + return nil, err + } + tmpl, err := template.New("root").ParseFS(bulkweb.Files, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("parse bulk templates: %w", err) + } + s := &Server{ + cfg: cfg, + logger: logger, + templates: tmpl, + client: &http.Client{ + Timeout: cfg.RequestTimeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + mux: http.NewServeMux(), + } + s.routes() + return s.securityHeaders(s.mux), nil +} + +func (s *Server) routes() { + staticFS, _ := fs.Sub(bulkweb.Files, "static") + s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + s.mux.HandleFunc("GET /", s.handleIndex) + s.mux.HandleFunc("GET /api/config", s.handleConfig) + s.mux.HandleFunc("POST /api/render-batch", s.handleRenderBatch) + s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok\n") + }) +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = s.templates.ExecuteTemplate(w, "index.html", pageData{ + Name: s.cfg.PublicName, Version: Version, DisclosureBaseURL: s.cfg.DisclosureBaseURL, + GeneratorURL: s.cfg.GeneratorURL, MaxURLs: s.cfg.MaxURLs, + }) +} + +func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(publicConfig{ + DisclosureBaseURL: s.cfg.DisclosureBaseURL, + GeneratorURL: s.cfg.GeneratorURL, + MaxURLs: s.cfg.MaxURLs, + Version: Version, + }) +} + +func (s *Server) handleRenderBatch(w http.ResponseWriter, r *http.Request) { + body := http.MaxBytesReader(w, r.Body, 1<<20) + defer body.Close() + dec := json.NewDecoder(body) + dec.DisallowUnknownFields() + var request batchRequest + if err := dec.Decode(&request); err != nil { + s.problem(w, http.StatusBadRequest, "invalid_json", "Request body must be valid JSON.") + return + } + if err := ensureEOF(dec); err != nil { + s.problem(w, http.StatusBadRequest, "invalid_json", "Request body must contain exactly one JSON object.") + return + } + if len(request.Subjects) == 0 { + s.problem(w, http.StatusBadRequest, "missing_subjects", "At least one subject URL is required.") + return + } + if len(request.Subjects) > s.cfg.MaxURLs { + s.problem(w, http.StatusRequestEntityTooLarge, "too_many_subjects", fmt.Sprintf("At most %d subject URLs are allowed per batch.", s.cfg.MaxURLs)) + return + } + if len(request.Template) > 24<<10 { + s.problem(w, http.StatusRequestEntityTooLarge, "template_too_large", "Template query is too large.") + return + } + + templateValues, err := url.ParseQuery(strings.TrimPrefix(strings.TrimSpace(request.Template), "?")) + if err != nil { + s.problem(w, http.StatusBadRequest, "invalid_template", "Template must be a valid URL query string.") + return + } + templateValues.Del("subject") + + results := make([]batchResult, len(request.Subjects)) + jobs := make(chan int) + var wg sync.WaitGroup + workers := min(s.cfg.Workers, len(request.Subjects)) + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + for idx := range jobs { + subject := strings.TrimSpace(request.Subjects[idx]) + results[idx] = s.renderOne(r.Context(), templateValues, subject) + } + }() + } + for idx := range request.Subjects { + jobs <- idx + } + close(jobs) + wg.Wait() + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(batchResponse{Results: results}) +} + +func (s *Server) renderOne(ctx context.Context, templateValues url.Values, subject string) batchResult { + if err := validateSubject(subject); err != nil { + return batchResult{Subject: subject, Error: err.Error()} + } + values := cloneValues(templateValues) + values.Set("subject", subject) + endpoint := s.cfg.CoreInternalURL + "/v1/render?" + values.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return batchResult{Subject: subject, Error: "could not build core request"} + } + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + s.logger.Warn("core render failed", "error", err) + return batchResult{Subject: subject, Error: "disclosure core is unavailable"} + } + defer resp.Body.Close() + payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return batchResult{Subject: subject, Error: "could not read disclosure core response"} + } + if resp.StatusCode != http.StatusOK { + message := coreErrorMessage(payload) + if message == "" { + message = fmt.Sprintf("disclosure core returned HTTP %d", resp.StatusCode) + } + return batchResult{Subject: subject, Error: message} + } + if !json.Valid(payload) { + return batchResult{Subject: subject, Error: "disclosure core returned invalid JSON"} + } + return batchResult{Subject: subject, OK: true, Data: json.RawMessage(payload)} +} + +func validateSubject(raw string) error { + u, err := url.ParseRequestURI(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { + return errors.New("subject must be an absolute http(s) URL without credentials") + } + return nil +} + +func coreErrorMessage(payload []byte) string { + var problem struct { + Detail string `json:"detail"` + Title string `json:"title"` + } + if json.Unmarshal(payload, &problem) != nil { + return "" + } + if strings.TrimSpace(problem.Detail) != "" { + return problem.Detail + } + return strings.TrimSpace(problem.Title) +} + +func ensureEOF(dec *json.Decoder) error { + var extra any + err := dec.Decode(&extra) + if errors.Is(err, io.EOF) { + return nil + } + if err == nil { + return errors.New("unexpected second JSON value") + } + return err +} + +func cloneValues(in url.Values) url.Values { + out := make(url.Values, len(in)) + for key, values := range in { + out[key] = append([]string(nil), values...) + } + return out +} + +func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) { + w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "about:blank", "title": code, "status": status, "detail": detail, + }) +} + +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") + started := time.Now() + next.ServeHTTP(w, r) + s.logger.Debug("request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(started)) + }) +} diff --git a/internal/bulk/server_test.go b/internal/bulk/server_test.go new file mode 100644 index 0000000..bf671d7 --- /dev/null +++ b/internal/bulk/server_test.go @@ -0,0 +1,125 @@ +package bulk + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestRenderBatchUsesFixedCoreAndReplacesSubject(t *testing.T) { + var mu sync.Mutex + var subjects []string + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/render" { + t.Fatalf("unexpected core path %q", r.URL.Path) + } + if got := r.URL.Query().Get("extent"); got != "partial" { + t.Fatalf("template extent = %q", got) + } + subject := r.URL.Query().Get("subject") + mu.Lock() + subjects = append(subjects, subject) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "subject": subject, + "declarationUrl": "https://public.example/declaration", + "badgeUrl": "https://public.example/badge.svg", + "manifestUrl": "https://public.example/manifest.json", + "html": "ok", + "markdown": "[ok]", + "jsonLd": map[string]any{"@type": "AIUsageDeclaration", "subject": subject}, + }) + })) + defer core.Close() + + h, err := New(Config{ + ListenAddress: ":0", CoreInternalURL: core.URL, + DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", + PublicName: "Bulk", MaxURLs: 10, Workers: 2, RequestTimeout: 2 * time.Second, + }, testLogger()) + if err != nil { + t.Fatal(err) + } + + body := `{"template":"extent=partial&subject=https%3A%2F%2Fold.example%2Fignored","subjects":["https://content.example/a","https://content.example/b"]}` + r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var response batchResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Results) != 2 || !response.Results[0].OK || !response.Results[1].OK { + t.Fatalf("unexpected results: %#v", response.Results) + } + mu.Lock() + defer mu.Unlock() + if len(subjects) != 2 { + t.Fatalf("core calls = %d", len(subjects)) + } + seen := map[string]bool{} + for _, subject := range subjects { + seen[subject] = true + } + if !seen["https://content.example/a"] || !seen["https://content.example/b"] || seen["https://old.example/ignored"] { + t.Fatalf("subjects sent to core: %#v", subjects) + } +} + +func TestRenderBatchRejectsNonHTTPSubjectWithoutCallingCore(t *testing.T) { + calls := 0 + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusOK) + })) + defer core.Close() + + h, err := New(Config{ListenAddress: ":0", CoreInternalURL: core.URL, DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", PublicName: "Bulk", MaxURLs: 10, Workers: 1, RequestTimeout: 2 * time.Second}, testLogger()) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(`{"template":"extent=partial","subjects":["file:///etc/passwd"]}`)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var response batchResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Results) != 1 || response.Results[0].OK || !strings.Contains(response.Results[0].Error, "http(s)") { + t.Fatalf("unexpected response: %#v", response.Results) + } + if calls != 0 { + t.Fatalf("core was called %d times for invalid subject", calls) + } +} + +func TestRenderBatchHonoursConfiguredLimit(t *testing.T) { + h, err := New(Config{ListenAddress: ":0", CoreInternalURL: "http://127.0.0.1:9", DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", PublicName: "Bulk", MaxURLs: 1, Workers: 1, RequestTimeout: time.Second}, testLogger()) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(`{"template":"extent=partial","subjects":["https://example.org/a","https://example.org/b"]}`)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go index 30493bd..f5f448c 100644 --- a/internal/i18n/catalog.go +++ b/internal/i18n/catalog.go @@ -38,7 +38,7 @@ var catalogs = map[string]Locale{ "field_activities": "Tätigkeiten", "activities_help": "Kommagetrennte Standardwerte, zum Beispiel research, summarisation oder translation.", "review_help": "Nur den tatsächlich durchgeführten Prüfprozess angeben. Eine formale Prüfung (z. B. Rechtschreibung) ist keine substanzielle menschliche Prüfung oder redaktionelle Kontrolle im Sinne von Art. 50 Abs. 4 AI Act.", "field_subject": "URL des gekennzeichneten Inhalts", "legal_context_legend": "Rechtlicher Kontext (Selbsteinordnung)", "legal_context_help": "Diese Angaben helfen bei der Art.-50-Einordnung. Sie sind keine automatische Rechtsentscheidung. Mehrere Kategorien können zutreffen.", "legal_deepfake": "Deepfake / realitätsähnliche KI-Manipulation", "legal_deepfake_help": "Nur auswählen, wenn Bild, Audio oder Video bestehenden oder plausibel existierenden Personen, Objekten, Orten, Entitäten oder Ereignissen ähnelt und fälschlich authentisch oder wahr erscheinen kann.", "legal_public_interest": "KI-generierter oder manipulierter Text zu einer Angelegenheit von öffentlichem Interesse", "legal_public_interest_help": "Zum Beispiel Politik, öffentliche Verwaltung, Grundrechte, Sicherheit, Gesundheit, Umwelt, Verbrauchersicherheit oder relevante wirtschaftliche, wissenschaftliche oder kulturelle Entwicklungen.", "legal_creative": "Evident künstlerisch / kreativ / satirisch / fiktional", "legal_creative_help": "Bei Deepfakes in solchen Werken kann die Offenlegung in einer angemessenen Form erfolgen, die Darstellung oder Genuss des Werks nicht beeinträchtigt.", "legal_other_voluntary": "Andere / freiwillige Transparenz", "legal_assessment_heading": "Vorsichtige Art.-50-Einschätzung", "legal_assessment_none": "Für die angegebenen Inhalte wurde keine inhaltsspezifische Offenlegungskategorie aus Art. 50 Abs. 4 erkannt. Das ist keine Aussage dazu, ob andere Pflichten aus Art. 50 oder sonstigem Recht greifen; die Kennzeichnung wird hier als freiwillige Transparenz behandelt.", "legal_assessment_voluntary": "Die Angaben sprechen derzeit für eine freiwillige Transparenzkennzeichnung; daraus folgt keine Aussage, dass sonstige AI-Act-Pflichten nicht gelten.", "legal_assessment_deepfake": "Auf Grundlage der Selbsteinordnung als Deepfake spricht vieles dafür, dass eine klare und unterscheidbare Offenlegung spätestens bei der ersten Exposition erforderlich ist.", "legal_assessment_deepfake_creative": "Der Inhalt ist als Deepfake und zugleich als evident künstlerisch, kreativ, satirisch oder fiktional eingeordnet. Eine Offenlegung bleibt grundsätzlich relevant, kann aber in angemessener Weise erfolgen, die Darstellung oder Genuss des Werks nicht beeinträchtigt.", "legal_assessment_public_required": "Auf Grundlage der Angaben spricht vieles dafür, dass der KI-generierte oder manipulierte Text zu einer Angelegenheit von öffentlichem Interesse klar offengelegt werden sollte. Eine ausreichende substanzielle menschliche Prüfung/redaktionelle Kontrolle zusammen mit ausdrücklich übernommener redaktioneller Verantwortung ist nicht vollständig dokumentiert.", "legal_assessment_public_exemption": "Die Angaben dokumentieren eine substanzielle menschliche Prüfung oder redaktionelle Kontrolle sowie eine ausdrücklich benannte redaktionelle Verantwortung. Daher kann die Ausnahme für bestimmte Texte von öffentlichem Interesse nach Art. 50 Abs. 4 in Betracht kommen. Eine freiwillige Transparenzkennzeichnung bleibt möglich.", "legal_assessment_inconsistent": "Die gewählte rechtliche Kategorie passt nicht vollständig zu den strukturierten Inhaltsangaben. Bitte prüfe KI-Anteil und Inhaltsbestandteile.", "badge_legal_disclosure": "KI-generierter / manipulierter Inhalt", "badge_public_interest": "KI-generierte / bearbeitete Inhalte", "fact_legal_context": "Rechtlicher Kontext (Selbsteinordnung)", "field_responsibility_role": "Art der redaktionellen Verantwortung", "responsibility_none": "Nicht angegeben", "responsibility_publisher": "Veröffentlichende Person/Organisation übernimmt die redaktionelle Verantwortung", "responsibility_other": "Andere verantwortliche Stelle", "field_responsible": "Redaktionell verantwortliche Person/Organisation", "responsible_placeholder": "Name oder Organisation", "field_responsible_url": "Nachweis/Impressum zur redaktionellen Verantwortung", "field_language": "Ausgabesprache", "field_theme": "Darstellung", "theme_mono": "Monochrom", "theme_color": "Farbig", - "preview": "Vorschau", "copy_html": "HTML kopieren", "copied": "Kopiert", + "preview": "Vorschau", "copy_html": "HTML kopieren", "copied": "Kopiert", "open_bulk": "Als Bulk-Vorlage öffnen", "pro_eyebrow": "Pro-Anpassung", "pro_title": "Eigene Texte und Badge-Designs", "pro_enabled": "Diese Instanz besitzt eine gültige Pro-Lizenz. Individuelle Texte und Farben können verwendet werden.", "pro_locked": "Individuelle Titel, Beschreibungstexte, Badge-Beschriftungen und Farben sind in der Pro-Ausgabe verfügbar.", "custom_title": "Eigener Erklärungstitel", "custom_description": "Eigene Beschreibung", "custom_badge_label": "Eigene Badge-Beschriftung links", "custom_badge_message": "Eigene Badge-Beschriftung rechts", @@ -75,7 +75,7 @@ var catalogs = map[string]Locale{ "generator_eyebrow": "Generator", "generator_title": "Create an embed", "generator_intro": "Choose a template or record AI use systematically by content component. The preview, embed code and machine-readable declaration are generated immediately.", "field_preset": "Preset", "option_custom": "Custom structure", "field_component": "Component", "field_extent": "AI contribution", "field_review": "Human review", "field_activities": "Activities", "activities_help": "Comma-separated standard values such as research, summarisation or translation.", "review_help": "Record only the review process that actually took place. A merely formal check (for example spelling or grammar) is not substantive human review or editorial control for the purposes of Article 50(4) AI Act.", "field_subject": "URL of the labelled content", "field_responsibility_role": "Editorial responsibility type", "responsibility_none": "Not specified", "responsibility_publisher": "Publishing person/organisation assumes editorial responsibility", "responsibility_other": "Other responsible party", "field_responsible": "Editorially responsible person/organisation (optional)", "responsible_placeholder": "Name or organisation", "field_responsible_url": "Evidence/imprint for editorial responsibility (optional)", "field_language": "Output language", "field_theme": "Appearance", "theme_mono": "Monochrome", "theme_color": "Colour", - "preview": "Preview", "copy_html": "Copy HTML", "copied": "Copied", "pro_eyebrow": "Pro customisation", "pro_title": "Custom copy and badge designs", + "preview": "Preview", "copy_html": "Copy HTML", "copied": "Copied", "open_bulk": "Open as bulk template", "pro_eyebrow": "Pro customisation", "pro_title": "Custom copy and badge designs", "pro_enabled": "This instance has a valid Pro licence. Custom copy and colours are available.", "pro_locked": "Custom titles, descriptions, badge labels and colours are available in the Pro edition.", "custom_title": "Custom declaration title", "custom_description": "Custom description", "custom_badge_label": "Custom left badge label", "custom_badge_message": "Custom right badge label", "custom_left_color": "Left colour", "custom_right_color": "Right colour", "pro_required": "Pro licence required", "api_badge_title": "Badge endpoint", "api_badge_desc": "Standard badges are generated from presets and structured parameters. Pro adds custom copy and colours.", "api_manifest_title": "Manifest", "api_manifest_desc": "The JSON-LD manifest can be linked, validated or included in build pipelines.", diff --git a/internal/marketing/content.go b/internal/marketing/content.go index 4050bbb..507ab1e 100644 --- a/internal/marketing/content.go +++ b/internal/marketing/content.go @@ -183,13 +183,13 @@ func Build(lang, productName, baseURL, salesURL, contactURL string, prices Price # Adjust BASE_URL and PUBLIC_NAME in .env docker compose up -d --build curl -fsS http://localhost:8080/readyz`}, - {ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.7.0 . + {ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.8.0 . docker run -d --name ai-disclosure \ -p 8080:8080 \ -e BASE_URL=https://ai.example.org \ -e PUBLIC_NAME="AI Usage Disclosure" \ --read-only --tmpfs /tmp \ - ai-disclosure-standard:1.7.0`}, + ai-disclosure-standard:1.8.0`}, {ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, Domain und TLS-Secret in deploy/kubernetes.yaml ersetzen kubectl create secret generic ai-disclosure-license \ --from-literal=token='...' diff --git a/openapi.yaml b/openapi.yaml index 7829406..80ffc66 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: AI Usage Disclosure API - version: 1.7.0 + version: 1.8.0 description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities. servers: - url: https://ai.example.org @@ -129,6 +129,59 @@ paths: application/ld+json: schema: {$ref: './schema/declaration.schema.json'} "403": {$ref: '#/components/responses/ProRequired'} + /v1/render: + get: + summary: Render reusable embedding artifacts from declaration query parameters + description: Returns HTML, Markdown and the parsed JSON-LD declaration from the same core logic used by the normal generator. The optional bulk container calls this endpoint for each subject URL. + parameters: + - {$ref: '#/components/parameters/preset'} + - {$ref: '#/components/parameters/extent'} + - {$ref: '#/components/parameters/language'} + - {$ref: '#/components/parameters/component'} + - {$ref: '#/components/parameters/activities'} + - {$ref: '#/components/parameters/review'} + - {name: subject, in: query, schema: {type: string, format: uri}} + - {$ref: '#/components/parameters/legalContext'} + - {$ref: '#/components/parameters/legalRole'} + - {$ref: '#/components/parameters/useContext'} + - {$ref: '#/components/parameters/outputDate'} + - {$ref: '#/components/parameters/deepfakeAssessment'} + - {$ref: '#/components/parameters/publicInterestAssessment'} + - {$ref: '#/components/parameters/creativeWorkAssessment'} + - {$ref: '#/components/parameters/lawEnforcementAuthorization'} + - {$ref: '#/components/parameters/author'} + - {$ref: '#/components/parameters/authorUrl'} + - {$ref: '#/components/parameters/responsibleRole'} + - {$ref: '#/components/parameters/responsible'} + - {$ref: '#/components/parameters/responsibleUrl'} + - {$ref: '#/components/parameters/complaintName'} + - {$ref: '#/components/parameters/complaintEmail'} + - {$ref: '#/components/parameters/complaintUrl'} + - {$ref: '#/components/parameters/assurance'} + - {$ref: '#/components/parameters/customTitle'} + - {$ref: '#/components/parameters/customDescription'} + - {$ref: '#/components/parameters/badgeLabel'} + - {$ref: '#/components/parameters/badgeMessage'} + - {$ref: '#/components/parameters/leftColor'} + - {$ref: '#/components/parameters/rightColor'} + - {$ref: '#/components/parameters/theme'} + responses: + "200": + description: Rendered artifacts + content: + application/json: + schema: + type: object + required: [declarationUrl, badgeUrl, manifestUrl, html, markdown, jsonLd] + properties: + subject: {type: string, format: uri} + declarationUrl: {type: string, format: uri} + badgeUrl: {type: string, format: uri} + manifestUrl: {type: string, format: uri} + html: {type: string} + markdown: {type: string} + jsonLd: {$ref: './schema/declaration.schema.json'} + "403": {$ref: '#/components/responses/ProRequired'} /v1/validate: post: summary: Validate an AI usage declaration diff --git a/web/static/app.js b/web/static/app.js index 46ca6c5..f36ce27 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -431,6 +431,17 @@ ? `[![${alt}](${badgeURL})](${declarationURL}) **${assessment.badge}**` : `[![${alt}](${badgeURL})](${declarationURL})`; $('json-code').value = manifestURL; + const bulkLink = $('bulk-template-link'); + if (bulkLink) { + bulkLink.hidden = !cfg.bulkURL; + if (cfg.bulkURL) { + const bulkParams = new URLSearchParams(params); + bulkParams.delete('subject'); + bulkParams.delete('link'); + const payload = {version: 1, query: bulkParams.toString(), theme: $('theme').value}; + bulkLink.href = `${cfg.bulkURL}/#template=${encodeURIComponent(JSON.stringify(payload))}`; + } + } } function enforceLegalExclusivity(changedId) { diff --git a/web/templates/background.html b/web/templates/background.html index b31ad3f..0521f10 100644 --- a/web/templates/background.html +++ b/web/templates/background.html @@ -158,7 +158,7 @@ - + diff --git a/web/templates/index.html b/web/templates/index.html index dc2db20..acd1f67 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -276,7 +276,7 @@ - +
{{index .Text "ai_act_embed_label"}} {{index .Text "ai_act_embed_text"}}
{{index .Text "ai_act_machine_label"}} {{index .Text "ai_act_machine_text"}}
{{index .Text "emoji_compliance_label"}} {{index .Text "emoji_compliance_text"}}
@@ -289,7 +289,7 @@

HA

{{index .Text "api_stateless_title"}}

{{index .Text "api_stateless_desc"}}

- + diff --git a/web/templates/marketing.html b/web/templates/marketing.html index 9e8ff95..3a307ec 100644 --- a/web/templates/marketing.html +++ b/web/templates/marketing.html @@ -119,7 +119,7 @@ - +