This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because one or more lines are too long
@@ -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 => `<!-- ${item.subject} -->\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();
|
||||
})();
|
||||
@@ -0,0 +1,247 @@
|
||||
{{define "index.html"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Name}}</title>
|
||||
<meta name="description" content="Mehrere AI-Disclosure-Kennzeichnungen aus einer gemeinsamen Vorlage erzeugen.">
|
||||
<link rel="stylesheet" href="/static/bulk.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/">{{.Name}}</a>
|
||||
<nav>
|
||||
<a href="{{.GeneratorURL}}">Generator</a>
|
||||
<a href="{{.DisclosureBaseURL}}/datenschutz">Datenschutz</a>
|
||||
<a href="{{.DisclosureBaseURL}}/impressum">Impressum</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Bulk Generator</p>
|
||||
<h1>Einmal definieren.<br>Viele Inhalte kennzeichnen.</h1>
|
||||
<p class="lead">Ü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.</p>
|
||||
<div class="notice privacy-note"><strong>Datensparsam:</strong> 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.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel workflow" aria-labelledby="workflow-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">1 · Kennzeichnung</p>
|
||||
<h2 id="workflow-title">Vorlage festlegen</h2>
|
||||
<p>Am sichersten ist die Übernahme direkt aus dem bestehenden Generator. Alternativ kannst du eine Declaration-URL oder deren Query-String einfügen.</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<a class="button secondary" id="open-generator" href="{{.GeneratorURL}}" target="_blank" rel="noopener">Kennzeichnung im Generator definieren</a>
|
||||
<select id="saved-template-select" aria-label="Gespeicherte Vorlage">
|
||||
<option value="">Gespeicherte Vorlage wählen …</option>
|
||||
</select>
|
||||
<button type="button" class="button subtle" id="delete-template">Vorlage löschen</button>
|
||||
</div>
|
||||
|
||||
<label class="field wide">Declaration-URL oder Query-String
|
||||
<textarea id="template-input" rows="5" spellcheck="false" placeholder="https://…/declaration?mode=article&textExtent=partial&…"></textarea>
|
||||
<small>`subject` wird beim Laden entfernt; die jeweilige Inhalts-URL setzt der Bulk-Lauf später selbst.</small>
|
||||
</label>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<button type="button" id="load-template">Vorlage laden</button>
|
||||
<label class="inline-field">Vorlagenname
|
||||
<input id="template-name" maxlength="80" placeholder="z. B. Artikel – redaktionell geprüft">
|
||||
</label>
|
||||
<button type="button" class="button secondary" id="save-template">Im Browser speichern</button>
|
||||
</div>
|
||||
|
||||
<div id="template-status" class="status" role="status" aria-live="polite">Noch keine Vorlage geladen.</div>
|
||||
<details class="details-box">
|
||||
<summary>Aktive Parameter anzeigen</summary>
|
||||
<dl id="template-params" class="parameter-list"></dl>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="profile-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">2 · Wiederkehrende Daten</p>
|
||||
<h2 id="profile-title">Website-Profil</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<select id="profile-select" aria-label="Website-Profil">
|
||||
<option value="">Website-Profil wählen …</option>
|
||||
</select>
|
||||
<button type="button" class="button subtle" id="new-profile">Neues Profil</button>
|
||||
<button type="button" class="button subtle danger" id="delete-profile">Profil löschen</button>
|
||||
</div>
|
||||
|
||||
<div class="profile-grid">
|
||||
<label>Profilname
|
||||
<input id="profile-name" maxlength="80" placeholder="z. B. example.org">
|
||||
</label>
|
||||
<label>Sprache
|
||||
<select id="profile-lang">
|
||||
<option value="">Aus Vorlage übernehmen</option>
|
||||
<option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
|
||||
<option value="es">Español</option><option value="it">Italiano</option><option value="nl">Nederlands</option>
|
||||
<option value="pt">Português</option><option value="pl">Polski</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>AI-Act-Rolle
|
||||
<select id="profile-role">
|
||||
<option value="">Aus Vorlage übernehmen</option>
|
||||
<option value="deployer">Deployer</option>
|
||||
<option value="provider">Provider</option>
|
||||
<option value="both">Beides</option>
|
||||
<option value="unsure">Unklar</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nutzungskontext
|
||||
<select id="profile-use-context">
|
||||
<option value="">Aus Vorlage übernehmen</option>
|
||||
<option value="professional">Beruflich / organisatorisch</option>
|
||||
<option value="personalNonProfessional">Rein persönlich / nicht beruflich</option>
|
||||
<option value="unsure">Unklar</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nachweisgrundlage
|
||||
<select id="profile-assurance">
|
||||
<option value="">Aus Vorlage übernehmen</option>
|
||||
<option value="selfDeclared">Selbstauskunft</option>
|
||||
<option value="technicallyRecorded">Technisch erfasst</option>
|
||||
<option value="signed">Signiert</option>
|
||||
<option value="verified">Verifiziert</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="subsection wide">
|
||||
<h3>Autor / Byline <span>optional</span></h3>
|
||||
<div class="profile-grid nested">
|
||||
<label>Autor/in
|
||||
<input id="profile-author" maxlength="200" placeholder="Name">
|
||||
</label>
|
||||
<label>Autoren-URL
|
||||
<input id="profile-author-url" type="url" placeholder="https://example.org/autor">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection wide">
|
||||
<h3>Redaktionelle Verantwortung <span>nur eintragen, wenn sie zur Vorlage passt</span></h3>
|
||||
<div class="profile-grid nested">
|
||||
<label>Rolle
|
||||
<select id="profile-responsible-role">
|
||||
<option value="">Aus Vorlage übernehmen / keine</option>
|
||||
<option value="publisher">Publisher</option>
|
||||
<option value="other">Andere verantwortliche Stelle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Name / Organisation
|
||||
<input id="profile-responsible" maxlength="200" placeholder="Redaktion / Organisation">
|
||||
</label>
|
||||
<label class="wide">Impressum / Verantwortlichkeits-URL
|
||||
<input id="profile-responsible-url" type="url" placeholder="https://example.org/impressum">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection wide">
|
||||
<h3>Beschwerde- / Rückmeldestelle <span>Best Practice</span></h3>
|
||||
<div class="profile-grid nested">
|
||||
<label>Name
|
||||
<input id="profile-complaint-name" maxlength="200" placeholder="Redaktion / Ombudsstelle">
|
||||
</label>
|
||||
<label>E-Mail
|
||||
<input id="profile-complaint-email" type="email" placeholder="feedback@example.org">
|
||||
</label>
|
||||
<label class="wide">Kontakt-URL
|
||||
<input id="profile-complaint-url" type="url" placeholder="https://example.org/kontakt">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar wrap">
|
||||
<button type="button" id="save-profile">Profil speichern</button>
|
||||
<button type="button" class="button secondary" id="export-browser-data">Profile & Vorlagen exportieren</button>
|
||||
<label class="button secondary file-button">Importieren<input id="import-browser-data" type="file" accept="application/json,.json"></label>
|
||||
<button type="button" class="button subtle danger" id="clear-browser-data">Alle lokalen Daten löschen</button>
|
||||
</div>
|
||||
<p class="help">Gespeichert werden nur die von dir angelegten Profile und Vorlagen in <code>localStorage</code>. Eine Synchronisierung zum Server findet nicht statt.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="urls-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">3 · Inhalte</p>
|
||||
<h2 id="urls-title">URLs oder Pfade einfügen</h2>
|
||||
<p>Eine URL bzw. ein Pfad pro Zeile. Bei relativen Pfaden wird die optionale Basis-URL verwendet. Eine einspaltige CSV-Datei mit der Überschrift <code>url</code> kann ebenfalls direkt eingefügt werden.</p>
|
||||
</div>
|
||||
<div class="url-grid">
|
||||
<label>Basis-URL für relative Pfade
|
||||
<input id="base-url" type="url" placeholder="https://example.org">
|
||||
</label>
|
||||
<div class="counter-card" id="url-counter" aria-live="polite"><strong>0</strong><span>gültige URLs</span></div>
|
||||
</div>
|
||||
<label class="field wide">URL-Liste
|
||||
<textarea id="url-input" rows="12" spellcheck="false" placeholder="/artikel/eins /artikel/zwei https://other.example/artikel/drei"></textarea>
|
||||
</label>
|
||||
<div id="url-messages" class="messages" aria-live="polite"></div>
|
||||
<div class="toolbar wrap">
|
||||
<button type="button" id="generate">Kennzeichnungen erzeugen</button>
|
||||
<button type="button" class="button subtle" id="clear-urls">URL-Liste leeren</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel results-panel" id="results-section" aria-labelledby="results-title" hidden>
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">4 · Ausgabe</p>
|
||||
<h2 id="results-title">Ergebnisse</h2>
|
||||
<p id="result-summary"></p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar wrap export-buttons">
|
||||
<button type="button" data-copy="html">HTML kopieren</button>
|
||||
<button type="button" class="button secondary" data-download="html">HTML herunterladen</button>
|
||||
<button type="button" data-copy="markdown">Markdown kopieren</button>
|
||||
<button type="button" class="button secondary" data-download="markdown">Markdown herunterladen</button>
|
||||
<button type="button" data-copy="json">JSON-LD kopieren</button>
|
||||
<button type="button" class="button secondary" data-download="json">JSON-LD herunterladen</button>
|
||||
<button type="button" class="button secondary" data-download="jsonl">JSONL herunterladen</button>
|
||||
<button type="button" class="button secondary" data-download="csv">CSV herunterladen</button>
|
||||
</div>
|
||||
|
||||
<div class="result-table-wrap">
|
||||
<table class="result-table">
|
||||
<thead><tr><th>Inhalt</th><th>Status</th><th>Links</th></tr></thead>
|
||||
<tbody id="result-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="output-grid">
|
||||
<label>HTML<textarea id="output-html" rows="12" readonly></textarea></label>
|
||||
<label>Markdown<textarea id="output-markdown" rows="12" readonly></textarea></label>
|
||||
<label>JSON-LD<textarea id="output-json" rows="12" readonly></textarea></label>
|
||||
<label>CSV<textarea id="output-csv" rows="8" readonly></textarea></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-panel">
|
||||
<h2>Was dieser Container bewusst nicht tut</h2>
|
||||
<ul class="plain-list">
|
||||
<li>keine Benutzerkonten und keine Datenbank</li>
|
||||
<li>kein Crawling und kein Abruf der eingegebenen Inhalts-URLs</li>
|
||||
<li>keine eigene rechtliche Entscheidungslogik</li>
|
||||
<li>keine dauerhafte Speicherung von URL-Listen oder Ergebnissen</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>AI Disclosure Bulk · {{.Version}} · Core-Ausgabe über {{.DisclosureBaseURL}}</span>
|
||||
<nav><a href="{{.DisclosureBaseURL}}/datenschutz">Datenschutz</a><a href="{{.DisclosureBaseURL}}/impressum">Impressum</a></nav>
|
||||
</footer>
|
||||
<script src="/static/bulk.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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},
|
||||
} {
|
||||
|
||||
@@ -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}})},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(`<a href="%s"><img src="%s" alt="%s"></a>`, html.EscapeString(declarationURL), html.EscapeString(badgeURL), html.EscapeString(alt))
|
||||
htmlMarkup := imageMarkup
|
||||
markdown := fmt.Sprintf(`[](%s)`, markdownAlt(alt), badgeURL, declarationURL)
|
||||
if assessment.RequiresDisclosure && theme == "emoji" {
|
||||
htmlMarkup += " <span>" + html.EscapeString(assessment.BadgeMessage) + "</span>"
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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": "<a>ok</a>",
|
||||
"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())
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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='...'
|
||||
|
||||
+54
-1
@@ -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
|
||||
|
||||
@@ -431,6 +431,17 @@
|
||||
? `[](${declarationURL}) **${assessment.badge}**`
|
||||
: `[](${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) {
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><span>{{.Background.Footer}} · App 1.7.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<footer><span>{{.Background.Footer}} · App 1.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<script src="/static/marketing.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
<label>Markdown<textarea id="markdown-code" readonly></textarea></label>
|
||||
<label>JSON-LD URL<textarea id="json-code" readonly></textarea></label>
|
||||
</div>
|
||||
<button id="copy-html" type="button">{{index .Text "copy_html"}}</button>
|
||||
<div class="toolbar wrap"><button id="copy-html" type="button">{{index .Text "copy_html"}}</button><a id="bulk-template-link" class="button secondary" href="#" target="_blank" rel="noopener" hidden>{{or (index .Text "open_bulk") "Bulk"}}</a></div>
|
||||
<div class="notice compliance-notice"><strong>{{index .Text "ai_act_embed_label"}}</strong> {{index .Text "ai_act_embed_text"}}</div>
|
||||
<div class="notice compliance-notice"><strong>{{index .Text "ai_act_machine_label"}}</strong> {{index .Text "ai_act_machine_text"}}</div>
|
||||
<div class="notice compliance-notice"><strong>{{index .Text "emoji_compliance_label"}}</strong> {{index .Text "emoji_compliance_text"}}</div>
|
||||
@@ -289,7 +289,7 @@
|
||||
<article><p class="eyebrow">HA</p><h2>{{index .Text "api_stateless_title"}}</h2><p>{{index .Text "api_stateless_desc"}}</p></article>
|
||||
</section>
|
||||
</main>
|
||||
<footer><span>AI Usage Disclosure · App 1.7.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<footer><span>AI Usage Disclosure · App 1.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<script nonce="{{.CSPNonce}}">window.APP_CONFIG={{.AppConfig}};</script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><span>{{.Marketing.Footer}} · App 1.7.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<footer><span>{{.Marketing.Footer}} · App 1.8.0 · Schema 1.3 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<script src="/static/marketing.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user