Impressum, Datenschutzerklärung und Barrierefreiheitshinweise
release-tag / release-image (push) Successful in 1m39s

This commit is contained in:
2026-07-21 03:29:41 +02:00
parent 1c133aedba
commit 0e6f71e1e6
23 changed files with 1432 additions and 88 deletions
+50 -1
View File
@@ -1,8 +1,58 @@
# Public origin only: scheme + host, no path/query/fragment.
BASE_URL=https://ai.trustednet.eu
PUBLIC_NAME=AI Usage Disclosure
CONTACT_URL=https://ai.trustednet.eu
SALES_URL=https://ai.trustednet.eu/product
DEFAULT_LANGUAGE=de
# Proxy and logging. Enable proxy headers only with explicit trusted CIDRs.
TRUST_PROXY=true
TRUSTED_PROXY_CIDRS=
LOG_CLIENT_IP=false
ENABLE_HSTS=true
# Prometheus is disabled by default. Enabling it requires a long bearer token;
# METRICS_TOKEN_FILE is also supported. Keep the endpoint on an internal network.
METRICS_ENABLED=false
METRICS_TOKEN=
# ---------------------------------------------------------------------------
# Legal operator data. Fill with accurate information before public operation.
# LEGAL_STRICT=true prevents startup while core legal/privacy fields are empty
# or contain REPLACE_ME/CHANGEME/TODO.
# ---------------------------------------------------------------------------
LEGAL_STRICT=true
LEGAL_NAME=REPLACE_ME
LEGAL_ADDRESS=REPLACE_ME
LEGAL_REPRESENTATIVE=
LEGAL_EMAIL=REPLACE_ME
LEGAL_PHONE=
LEGAL_REGISTER=
LEGAL_REGISTER_NUMBER=
LEGAL_VAT_ID=
# Only set these if the service contains journalistic-editorial content and
# § 18(2) MStV applies.
EDITORIAL_RESPONSIBLE_NAME=
EDITORIAL_RESPONSIBLE_ADDRESS=
DATA_PROTECTION_CONTACT=
HOSTING_PROVIDER=REPLACE_ME
HOSTING_ADDRESS=
LOG_RETENTION=REPLACE_ME
DATA_RECIPIENTS=
THIRD_COUNTRY_TRANSFERS=
SUPERVISORY_AUTHORITY_NAME=
SUPERVISORY_AUTHORITY_URL=
# One of: not_applicable, not_participating, participating.
# Assess applicability under the VSBG; do not copy a status blindly.
CONSUMER_DISPUTE_STATUS=REPLACE_ME
CONSUMER_DISPUTE_BODY=
CONSUMER_DISPUTE_URL=
ACCESSIBILITY_CONTACT=
ACCESSIBILITY_STATUS=
# Runtime license issued by the standalone Universal License Platform.
# No private keys or configurable public keys belong in this application.
@@ -13,4 +63,3 @@ LICENSE_SERVER_URL=https://license.trustednet.eu
LICENSE_CACHE_FILE=/data/license-lease.json
LICENSE_REFRESH_INTERVAL=15m
LICENSE_REQUEST_TIMEOUT=5s
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## Unreleased
- Added configurable Impressum, privacy and accessibility pages with visible placeholder warnings and strict production validation.
- Added secure defaults for client-IP logging, reverse-proxy trust, HSTS, CSP nonces and Prometheus access.
- Added legal/security deployment guidance and operator checklists.
- Added request limits, stricter JSON handling and configuration URL validation.
- Redacted internal licence metadata from the public capabilities endpoint.
- Added regression tests for legal routes and security controls.
## 1.6.2
- Added a dedicated, long-form background page at `/background`.
+32 -4
View File
@@ -14,7 +14,9 @@ Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur
- mehrsprachige Hintergrundseite zu Artikel 50 des EU AI Act, Kennzeichnungspflichten und freiwilliger Transparenz;
- optionale lizenzierte Funktionen für eigene Texte und Badge-Darstellung;
- Offline-, Hybrid- und Online-Prüfung über die eigenständige Universal License Platform;
- Health-, Readiness- und Prometheus-Endpunkte;
- 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.
## Start unter Windows
@@ -29,6 +31,9 @@ Danach:
Generator: http://localhost:8080/
Produktseite: http://localhost:8080/product
Hintergrund: http://localhost:8080/background
Impressum: http://localhost:8080/impressum
Datenschutz: http://localhost:8080/datenschutz
Barrierefrei: http://localhost:8080/barrierefreiheit
Healthcheck: http://localhost:8080/healthz
Funktionen: http://localhost:8080/v1/capabilities
```
@@ -131,14 +136,32 @@ Weitere Einzelheiten stehen in [`docs/LICENSE-INTEGRATION.md`](docs/LICENSE-INTE
## Konfiguration
### Betrieb und Sicherheit
| Variable | Standard | Bedeutung |
|---|---|---|
| `LISTEN_ADDRESS` | `:8080` | HTTP-Adresse |
| `BASE_URL` | `http://localhost:8080` | öffentliche kanonische URL und Domainprüfung |
| `BASE_URL` | `http://localhost:8080` | öffentliche Origin ohne Pfad, Query oder Fragment |
| `PUBLIC_NAME` | `AI Usage Disclosure` | sichtbarer Produktname |
| `CONTACT_URL` | Projektseite | Kontakt-/Informationsseite |
| `DEFAULT_LANGUAGE` | `de` | Standardsprache |
| `TRUST_PROXY` | `false` | Proxy-Header für Client-IP berücksichtigen |
| `TRUST_PROXY` | `false` | Proxy-Header nur berücksichtigen, wenn zusätzlich vertrauenswürdige Netze gesetzt sind |
| `TRUSTED_PROXY_CIDRS` | leer | kommagetrennte CIDRs der tatsächlich kontrollierten Reverse Proxies |
| `LOG_CLIENT_IP` | `false` | Client-IP in Anwendungslogs aufnehmen; aus Datenschutzgründen standardmäßig deaktiviert |
| `ENABLE_HSTS` | `true` | HSTS bei einer `https://`-Basis-URL senden |
| `METRICS_ENABLED` | `false` | `/metrics` aktivieren |
| `METRICS_TOKEN` | leer | bei aktiviertem `/metrics` verpflichtender Bearer-Token; auch als `METRICS_TOKEN_FILE` |
### Betreiber- und Datenschutzangaben
Die Seiten `/impressum`, `/datenschutz` und `/barrierefreiheit` werden aus Umgebungsvariablen erzeugt. Mindestens `LEGAL_NAME`, `LEGAL_ADDRESS`, `LEGAL_EMAIL`, `HOSTING_PROVIDER`, `LOG_RETENTION` und `CONSUMER_DISPUTE_STATUS` müssen vor öffentlichem Betrieb geprüft werden. Mit `LEGAL_STRICT=true` startet der Server nicht, solange Pflichtwerte fehlen oder Platzhalter wie `REPLACE_ME` enthalten.
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
| Variable | Standard | Bedeutung |
|---|---|---|
| `LICENSE_TOKEN` | leer | von der Universal License Platform ausgestellter Token |
| `LICENSE_MODE` | `offline` | Mindestmodus `offline`, `hybrid` oder `online` |
| `LICENSE_SERVER_URL` | leer | optionaler Prüfserver-Override |
@@ -147,19 +170,24 @@ Weitere Einzelheiten stehen in [`docs/LICENSE-INTEGRATION.md`](docs/LICENSE-INTE
| `LICENSE_REFRESH_INTERVAL` | `15m` | Hintergrundaktualisierung |
| `LICENSE_REQUEST_TIMEOUT` | `5s` | Timeout der Onlineprüfung |
Die mitgelieferten Rechtstexte sind eine technisch abgestimmte Vorlage, keine individuelle Rechtsberatung. Die konkrete Einordnung hängt unter anderem von Betreiber, Hosting, Vertragsmodell, Zielgruppe, Zusatzdiensten und redaktionellen Inhalten ab. Siehe [`docs/LEGAL-AND-SECURITY.md`](docs/LEGAL-AND-SECURITY.md) und den [`Reviewbericht vom 20. Juli 2026`](docs/REVIEW-2026-07-20.md).
## API
```text
GET /badge/{preset}.svg
GET /v1/badge.svg
GET /background
GET /impressum
GET /datenschutz
GET /barrierefreiheit
GET /declaration
GET /v1/declaration.json
POST /v1/validate
GET /v1/capabilities
GET /healthz
GET /readyz
GET /metrics
GET /metrics optional, standardmäßig deaktiviert
```
Beispiel für eine Artikelerklärung:
+17 -3
View File
@@ -2,6 +2,22 @@
Version 1.6.x is the supported line in this project archive.
## Reporting
Report suspected vulnerabilities privately to the project operator before public disclosure. Configure the operator's security contact outside this source archive and publish it through the project website or a `security.txt` file at the deployment edge.
## Secure defaults
- HTML responses use a restrictive Content Security Policy with a fresh nonce.
- Request IDs are length- and character-validated before they enter logs.
- Query strings and client IP addresses are not written to application logs by default.
- Forwarded client addresses are trusted only from explicitly configured proxy CIDRs.
- `/metrics` is disabled by default and requires a Bearer token when enabled.
- JSON validation requests are size-limited, strictly decoded and concurrency-limited.
- The reference container runs without root, Linux capabilities or a writable root filesystem.
See [`docs/LEGAL-AND-SECURITY.md`](docs/LEGAL-AND-SECURITY.md) for deployment controls that remain the operator's responsibility, including TLS, edge rate limits, patching, secret management, network segmentation and log deletion.
## License integration
- Customer installations configure only `LICENSE_TOKEN` and optional client settings.
@@ -9,8 +25,6 @@ Version 1.6.x is the supported line in this project archive.
- Public issuer and lease keys are embedded from `internal/app/trusted_keys.json` at build time.
- Key generation, license issuance, token registries, revocation and lease signing exist only in the standalone Universal License Platform.
- Use HTTPS for hybrid and online verification.
- Protect the hybrid cache directory from other local users; it contains only signed lease tokens, not private keys.
- Protect the hybrid cache directory from other local users; it contains signed lease tokens, not private keys.
Online mode fails closed if the platform is unavailable. Hybrid mode may continue only while a previously signed lease remains valid within the grace period encoded in the license.
Report suspected vulnerabilities privately to the project operator before public disclosure.
+3 -15
View File
@@ -1,20 +1,8 @@
services:
app:
build: .
image: ai-disclosure-standard:1.6.2-local
environment:
BASE_URL: "${BASE_URL:-http://localhost:8080}"
PUBLIC_NAME: "${PUBLIC_NAME:-AI Usage Disclosure}"
CONTACT_URL: "${CONTACT_URL:-https://b1tsblog.org/page/ai}"
DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}"
TRUST_PROXY: "${TRUST_PROXY:-true}"
LICENSE_TOKEN: "${LICENSE_TOKEN:-}"
LICENSE_MODE: "${LICENSE_MODE:-offline}"
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-}"
LICENSE_CACHE_FILE: /data/license-lease.json
LICENSE_REFRESH_INTERVAL: "${LICENSE_REFRESH_INTERVAL:-15m}"
LICENSE_REQUEST_TIMEOUT: "${LICENSE_REQUEST_TIMEOUT:-5s}"
image: git.send.nrw/sendnrw/ai-disclosure-standard:latest
env_file:
- .env
ports:
- "8080:8080"
volumes:
+22 -4
View File
@@ -18,10 +18,6 @@ spec:
metadata:
labels:
app.kubernetes.io/name: ai-disclosure
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: /metrics
spec:
securityContext:
runAsNonRoot: true
@@ -46,7 +42,29 @@ spec:
- name: DEFAULT_LANGUAGE
value: de
- name: TRUST_PROXY
value: "false"
- name: LOG_CLIENT_IP
value: "false"
- name: ENABLE_HSTS
value: "true"
- name: METRICS_ENABLED
value: "false"
# Replace the following values before deployment. LEGAL_STRICT
# deliberately prevents accidental public startup with placeholders.
- name: LEGAL_STRICT
value: "true"
- name: LEGAL_NAME
value: REPLACE_ME
- name: LEGAL_ADDRESS
value: REPLACE_ME
- name: LEGAL_EMAIL
value: REPLACE_ME
- name: HOSTING_PROVIDER
value: REPLACE_ME
- name: LOG_RETENTION
value: REPLACE_ME
- name: CONSUMER_DISPUTE_STATUS
value: REPLACE_ME
- name: LICENSE_TOKEN
valueFrom:
secretKeyRef:
+13 -3
View File
@@ -3,12 +3,22 @@ services:
app:
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.2
environment:
BASE_URL: https://ai.example.org
BASE_URL: https://ai.trustednet.eu
PUBLIC_NAME: AI Usage Disclosure
CONTACT_URL: https://b1tsblog.org/page/ai
SALES_URL: "${SALES_URL:-https://b1tsblog.org/page/ai}"
CONTACT_URL: https://ai.trustednet.eu
SALES_URL: "${SALES_URL:-https://ai.trustednet.eu}"
DEFAULT_LANGUAGE: de
TRUST_PROXY: "true"
LOG_CLIENT_IP: "false"
ENABLE_HSTS: "true"
METRICS_ENABLED: "false"
LEGAL_STRICT: "${LEGAL_STRICT:-true}"
LEGAL_NAME: "${LEGAL_NAME:-REPLACE_ME}"
LEGAL_ADDRESS: "${LEGAL_ADDRESS:-REPLACE_ME}"
LEGAL_EMAIL: "${LEGAL_EMAIL:-REPLACE_ME}"
HOSTING_PROVIDER: "${HOSTING_PROVIDER:-REPLACE_ME}"
LOG_RETENTION: "${LOG_RETENTION:-REPLACE_ME}"
CONSUMER_DISPUTE_STATUS: "${CONSUMER_DISPUTE_STATUS:-REPLACE_ME}"
LICENSE_TOKEN: "${LICENSE_TOKEN:-}"
LICENSE_MODE: "${LICENSE_MODE:-offline}"
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
+136
View File
@@ -0,0 +1,136 @@
# Rechtlicher und sicherer Produktivbetrieb
Stand: 20. Juli 2026
Diese Datei beschreibt die mitgelieferten technischen Schutzmaßnahmen und die Punkte, die ein Betreiber vor einer öffentlichen Bereitstellung selbst prüfen und vervollständigen muss. Sie ersetzt keine Rechtsberatung für den konkreten Einzelfall.
## 1. Vor dem ersten öffentlichen Start
1. `.env.example` nach `.env` kopieren und alle `REPLACE_ME`-Werte durch zutreffende Angaben ersetzen.
2. `LEGAL_STRICT=true` aktiv lassen. Der Dienst verweigert dann den Start, wenn Kernangaben fehlen.
3. Impressum, Datenschutzerklärung und Barrierefreiheitserklärung im fertig deployten System prüfen:
- `/impressum`
- `/datenschutz`
- `/barrierefreiheit`
4. Tatsächliche Hosting-, Proxy-, CDN-, DNS-, Logging-, Backup-, Monitoring- und Lizenzdienste mit der Datenschutzerklärung abgleichen.
5. Bei Verbraucherverträgen prüfen, welche Angaben nach dem Verbraucherstreitbeilegungsgesetz erforderlich sind. Die frühere EU-OS-Plattform wurde eingestellt; ein alter OS-Link sollte nicht übernommen werden.
6. Bei journalistisch-redaktionellen Angeboten prüfen, ob ein Verantwortlicher nach § 18 Abs. 2 MStV benannt werden muss.
7. Bei Angeboten an Verbraucher prüfen, ob das BFSG und die BFSGV anwendbar sind. Eine bloße Selbsterklärung ersetzt keinen Accessibility-Audit.
## 2. Anbieterkennzeichnung
Die Vorlage deckt typische Felder für § 5 DDG und § 18 MStV ab. Je nach Betreiber können weitere Angaben erforderlich sein, etwa:
- Rechtsform und Vertretungsberechtigte;
- Register, Registernummer und Registergericht;
- Umsatzsteuer-Identifikationsnummer;
- Aufsichtsbehörde und berufsrechtliche Angaben;
- redaktionell Verantwortliche mit Name und Anschrift;
- Erklärung zur Verbraucherstreitbeilegung.
Offizielle Grundlagen:
- § 5 DDG: https://www.gesetze-im-internet.de/ddg/__5.html
- § 18 MStV: https://www.gesetze-bayern.de/Content/Document/MStV-18
- § 36 VSBG: https://www.gesetze-im-internet.de/vsbg/__36.html
## 3. Datenschutz
Die ausgelieferte Weboberfläche verwendet keine Cookies, kein Tracking und keine Browser-Speicher wie Local Storage. Das allein macht einen Betrieb nicht automatisch datenschutzkonform. Relevant sind insbesondere die tatsächlichen Infrastruktur-Logs und zusätzlich eingebundene Dienste.
Technischer Standardzustand:
- kein Datenbank- oder Session-Speicher für Generator-Eingaben;
- keine Query-Strings in den Anwendungslogs;
- keine Client-IP in den Anwendungslogs, solange `LOG_CLIENT_IP=false` bleibt;
- URL-Parameter können dennoch in Browser-Verläufen, Reverse-Proxy-, CDN- oder Hosting-Logs erscheinen;
- Hybrid- und Online-Lizenzmodi kommunizieren mit dem konfigurierten Lizenzserver;
- öffentliche Erklärungs-URLs dürfen keine vertraulichen oder unnötigen personenbezogenen Daten enthalten.
Der Betreiber muss insbesondere festlegen und umsetzen:
- Rechtsgrundlagen und Zwecke;
- Empfänger und Auftragsverarbeiter;
- Lösch- und Aufbewahrungsfristen;
- Drittlandübermittlungen;
- technisch-organisatorische Maßnahmen;
- Prozesse für Betroffenenrechte und Datenschutzvorfälle.
Offizielle Grundlagen:
- Art. 13 DSGVO: https://eur-lex.europa.eu/eli/reg/2016/679/oj
- § 25 TDDDG: https://www.gesetze-im-internet.de/ttdsg/__25.html
## 4. Barrierefreiheit
Die Anwendung nutzt semantische Formulare und Tabellen, sichtbare Fokuszustände, responsive Layouts und Textalternativen. Sie erhebt ohne gesonderten Audit keinen Anspruch auf vollständige WCAG- oder EN-301-549-Konformität.
Für ein erfasstes Verbraucherangebot sind unter anderem zu prüfen:
- Tastaturbedienung und Fokusreihenfolge;
- Kontraste, Zoom und Reflow;
- verständliche Fehlermeldungen;
- Screenreader-Ausgabe;
- Alternativtexte eingebetteter Badges;
- Barrierefreiheit des vollständigen Bestell- oder Vertragspfads;
- gesetzlich verlangte Informationen zur Barrierefreiheit.
Offizielle Informationen: https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz
## 5. Sicherheitsstandard
Mitgeliefert werden unter anderem:
- restriktive Content Security Policy mit zufälliger Nonce je HTML-Antwort;
- `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `nosniff`, Referrer- und Permissions-Policy;
- HSTS bei HTTPS-Basis-URL;
- Größenlimit und striktes JSON-Decoding für den Validator;
- Begrenzung paralleler Validierungsanfragen;
- validierte und begrenzte Request-IDs;
- Proxy-Header nur aus ausdrücklich konfigurierten Proxy-Netzen;
- standardmäßig deaktivierte Client-IP-Logs und Prometheus-Metriken;
- verpflichtender Bearer-Schutz bei aktiviertem `/metrics`;
- Non-Root-Container, schreibgeschütztes Dateisystem, entfernte Linux-Capabilities und `no-new-privileges`.
Zusätzlich in der Betriebsumgebung erforderlich:
- TLS-Terminierung und sichere Zertifikatsverwaltung;
- Rate Limits am Edge/Ingress;
- regelmäßige Image- und Abhängigkeitsupdates;
- Secret-Management statt Klartext-Umgebungsvariablen, soweit möglich;
- Netzwerksegmentierung für Metriken und Lizenzserver;
- zentrale Log-Löschung entsprechend `LOG_RETENTION`;
- Backup-, Restore- und Incident-Response-Verfahren;
- Überwachung ohne unnötige personenbezogene Telemetrie.
## 6. Reverse Proxy
`TRUST_PROXY=true` darf nur zusammen mit `TRUSTED_PROXY_CIDRS` verwendet werden. Trage ausschließlich Netze ein, aus denen dein kontrollierter Reverse Proxy die Anwendung tatsächlich erreicht. Andernfalls könnten Clients weitergeleitete IP-Header vortäuschen.
Beispiel für einen ausschließlich lokalen Proxy:
```env
TRUST_PROXY=true
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
```
Docker-, Kubernetes- oder Cloud-Netze unterscheiden sich je Umgebung und dürfen nicht pauschal kopiert werden.
## 7. Metriken
`/metrics` ist standardmäßig nicht registriert. Für eine Aktivierung:
```env
METRICS_ENABLED=true
METRICS_TOKEN_FILE=/run/secrets/metrics-token
```
Der Bearer-Token schützt den Endpunkt auf Anwendungsebene. Zusätzlich sollte der Endpunkt nicht über den öffentlichen Ingress erreichbar sein.
## 8. KI-Transparenz
Der Standard unterstützt freiwillige Dokumentation und bestimmte Transparenz-Workflows. Er entscheidet nicht automatisch, ob Artikel 50 des AI Act auf einen konkreten Inhalt anwendbar ist. Die offiziellen Transparenzleitlinien der Europäischen Kommission wurden am 20. Juli 2026 veröffentlicht; Artikel 50 gilt ab dem 2. August 2026.
- AI Act: https://eur-lex.europa.eu/eli/reg/2024/1689/oj
- Leitlinien: https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems
- FAQ: https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
+85
View File
@@ -0,0 +1,85 @@
# Rechts- und Sicherheitsreview
Stand: 20. Juli 2026
## Einordnung
Dieses Review verbessert den technischen Standardzustand und stellt konfigurierbare Vorlagen für zentrale Betreiberinformationen bereit. Es ist keine individuelle Rechtsberatung und keine Garantie vollständiger Rechtskonformität. Die abschließende Prüfung muss anhand des tatsächlichen Betreibers, Sitzes, Geschäftsmodells, Hostings, Vertragsangebots und aller eingebundenen Dienste erfolgen.
## Ausgangslage
Im geprüften Archiv fehlten eigene Seiten für Anbieterkennzeichnung, Datenschutz und Barrierefreiheit. Zudem wurde die Client-IP bei jedem Request protokolliert, Proxy-Header konnten ohne explizite Netzbindung vertraut werden, der Metrik-Endpunkt war grundsätzlich registriert und der öffentliche Capability-Endpunkt konnte interne Lizenzmetadaten ausgeben.
## Umgesetzte Änderungen
### Rechtliche Informationen
- neue Routen `/impressum`, `/datenschutz` und `/barrierefreiheit`;
- zentrale Footer-Verlinkung auf allen HTML-Seiten;
- konfigurierbare Betreiber-, Register-, Steuer-, Datenschutz-, Hosting-, Streitbeilegungs- und Barrierefreiheitsangaben;
- sichtbare Warnung bei fehlenden Angaben;
- `LEGAL_STRICT=true` verhindert einen Produktivstart mit fehlenden Kernangaben oder typischen Platzhaltern;
- keine erfundenen Betreiberangaben und keine pauschale Behauptung vollständiger Barrierefreiheit;
- Hinweise auf die tatsächliche Verarbeitung von URL-Parametern, Infrastruktur-Logs und optionaler Online-Lizenzprüfung;
- kein veralteter Link zur eingestellten EU-OS-Plattform.
### Anwendungssicherheit und Datenschutz durch Voreinstellungen
- Client-IP-Logging standardmäßig deaktiviert;
- Query-Strings werden nicht in Anwendungslogs übernommen;
- Proxy-Header werden nur von explizit konfigurierten CIDR-Netzen ausgewertet;
- die weitergeleitete IP wird von rechts nach links über die vertrauenswürdige Proxy-Kette bestimmt, damit vorgeschobene Header-Werte nicht als Client-IP gelten;
- validierte und auf 64 Zeichen begrenzte Request-IDs;
- restriktive Content Security Policy mit zufälliger Nonce je HTML-Antwort;
- HSTS nur bei HTTPS-Konfiguration und ohne pauschales `includeSubDomains`;
- `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `nosniff`, Referrer- und Permissions-Policy;
- Validator mit 1-MiB-Limit, strikt erlaubten JSON-Feldern, Content-Type-Prüfung und Parallelitätsgrenze;
- `/metrics` standardmäßig nicht registriert; bei Aktivierung ist ein Bearer-Token zwingend;
- öffentliche Capability-Antwort enthält keine Lizenz-ID, Kundendaten, internen Serveradressen oder Diagnosedetails;
- externe Konfigurations-URLs werden auf absolute HTTP(S)-URLs ohne Zugangsdaten begrenzt;
- rechtliche Kontaktfelder werden als reine E-Mail-Adressen validiert;
- fehlerhafte boolesche Umgebungsvariablen und ungültige Proxy-CIDRs führen zum Startfehler statt zu einem stillen Fallback.
### Deployment
- Compose-, Kubernetes- und Swarm-Beispiele um die Rechts- und Sicherheitskonfiguration erweitert;
- sichere Containeroptionen beibehalten: Non-Root, read-only Root-Dateisystem, keine Linux-Capabilities, `no-new-privileges`;
- öffentliche Prometheus-Autodiscovery aus dem Kubernetes-Beispiel entfernt;
- Dokumentation für TLS, Rate Limits, Secrets, Netzsegmentierung, Löschfristen, Backups, Updates und Incident Response ergänzt.
## Noch durch den Betreiber zu erledigen
Vor einem öffentlichen Start sind mindestens folgende Punkte mit echten Angaben und der tatsächlichen Infrastruktur abzugleichen:
1. Firma/Name, ladungsfähige Anschrift, Vertretungsberechtigte, Register, Umsatzsteuer-ID und gegebenenfalls berufsrechtliche Angaben.
2. Redaktionell Verantwortliche, falls journalistisch-redaktionelle Inhalte unter § 18 Abs. 2 MStV angeboten werden.
3. Hosting-Anbieter, eingesetzte Reverse Proxies/CDNs, tatsächliche Logfelder, Löschfristen, Auftragsverarbeitungsverträge und mögliche Drittlandübermittlungen.
4. Anwendbarkeit und zutreffender Status nach dem Verbraucherstreitbeilegungsgesetz.
5. Anwendbarkeit von BFSG/BFSGV sowie ein tatsächlicher Accessibility-Test des vollständigen Angebots.
6. Datenschutzrechtliche Einordnung des Lizenzservers bei Hybrid- oder Online-Betrieb.
7. TLS, Edge-Rate-Limits, Secret-Management, Patch-Prozess, Schwachstellenscans, Monitoring, Backups und Incident-Response in der Zielumgebung.
8. Juristische Schlussprüfung der veröffentlichten Texte, insbesondere bei Verbraucherverträgen, reglementierten Berufen oder internationalen Angeboten.
## Technische Prüfung
Ausgeführt wurden:
```text
go test ./...
go vet ./...
cd third_party/license-platform-client && go test ./... && go vet ./...
```
Alle Prüfungen waren erfolgreich. Da die isolierte Prüfumgebung die in `go.mod` verlangte Go-1.26-Toolchain nicht herunterladen konnte, wurden die Tests mit der lokal vorhandenen Go-Version 1.23.2 und einer nur temporär angepassten Modfile ausgeführt. Die Projektdatei `go.mod` bleibt unverändert bei Go 1.26.
Zusätzlich wurden alle YAML-Dateien geparst, die Go-Dateien mit `gofmt` geprüft und Templates/JavaScript auf Inline-Eventhandler, Cookies sowie Browser-Speicherzugriffe durchsucht.
## Offizielle Ausgangspunkte
- § 5 DDG: https://www.gesetze-im-internet.de/ddg/__5.html
- § 18 MStV: https://www.gesetze-bayern.de/Content/Document/MStV-18
- Art. 13 DSGVO: https://eur-lex.europa.eu/eli/reg/2016/679/oj
- § 25 TDDDG: https://www.gesetze-im-internet.de/ttdsg/__25.html
- § 36 VSBG: https://www.gesetze-im-internet.de/vsbg/__36.html
- Bundesfachstelle Barrierefreiheit zum BFSG: https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz
- EU AI Act: https://eur-lex.europa.eu/eli/reg/2024/1689/oj
+229 -15
View File
@@ -1,6 +1,11 @@
package app
import (
"errors"
"fmt"
"net/mail"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
@@ -10,13 +15,44 @@ import (
)
type Config struct {
ListenAddress string
BaseURL string
PublicName string
ContactURL string
SalesURL string
DefaultLanguage string
TrustProxy bool
EnvironmentErrors []string
ListenAddress string
BaseURL string
PublicName string
ContactURL string
SalesURL string
DefaultLanguage string
TrustProxy bool
TrustedProxies []netip.Prefix
LogClientIP bool
EnableHSTS bool
MetricsEnabled bool
MetricsToken string
LegalStrict bool
LegalName string
LegalAddress string
LegalRepresentative string
LegalEmail string
LegalPhone string
LegalRegister string
LegalRegisterNumber string
LegalVATID string
EditorialResponsibleName string
EditorialResponsibleAddress string
DataProtectionContact string
HostingProvider string
HostingAddress string
LogRetention string
DataRecipients string
ThirdCountryTransfers string
SupervisoryAuthorityName string
SupervisoryAuthorityURL string
ConsumerDisputeStatus string
ConsumerDisputeBody string
ConsumerDisputeURL string
AccessibilityContact string
AccessibilityStatus string
LicenseToken string
LicenseMode licensekit.VerificationMode
@@ -33,19 +69,58 @@ type Config struct {
}
func ConfigFromEnv() Config {
contactURL := env("CONTACT_URL", "https://b1tsblog.org/page/ai")
var environmentErrors []string
contactURL := env("CONTACT_URL", "https://ai.trustednet.eu")
mode, err := licensekit.ParseMode(env("LICENSE_MODE", "offline"))
if err != nil {
environmentErrors = append(environmentErrors, "LICENSE_MODE is invalid")
mode = licensekit.ModeOffline
}
trustProxy := boolEnv("TRUST_PROXY", false, &environmentErrors)
logClientIP := boolEnv("LOG_CLIENT_IP", false, &environmentErrors)
enableHSTS := boolEnv("ENABLE_HSTS", true, &environmentErrors)
metricsEnabled := boolEnv("METRICS_ENABLED", false, &environmentErrors)
legalStrict := boolEnv("LEGAL_STRICT", false, &environmentErrors)
trustedProxies := prefixListEnv("TRUSTED_PROXY_CIDRS", &environmentErrors)
return Config{
ListenAddress: env("LISTEN_ADDRESS", ":8080"),
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"),
ContactURL: contactURL,
SalesURL: env("SALES_URL", contactURL),
DefaultLanguage: env("DEFAULT_LANGUAGE", "de"),
TrustProxy: strings.EqualFold(env("TRUST_PROXY", "false"), "true"),
EnvironmentErrors: environmentErrors,
ListenAddress: env("LISTEN_ADDRESS", ":8080"),
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"),
ContactURL: contactURL,
SalesURL: env("SALES_URL", contactURL),
DefaultLanguage: env("DEFAULT_LANGUAGE", "de"),
TrustProxy: trustProxy,
TrustedProxies: trustedProxies,
LogClientIP: logClientIP,
EnableHSTS: enableHSTS,
MetricsEnabled: metricsEnabled,
MetricsToken: secretEnv("METRICS_TOKEN"),
LegalStrict: legalStrict,
LegalName: env("LEGAL_NAME", ""),
LegalAddress: env("LEGAL_ADDRESS", ""),
LegalRepresentative: env("LEGAL_REPRESENTATIVE", ""),
LegalEmail: env("LEGAL_EMAIL", ""),
LegalPhone: env("LEGAL_PHONE", ""),
LegalRegister: env("LEGAL_REGISTER", ""),
LegalRegisterNumber: env("LEGAL_REGISTER_NUMBER", ""),
LegalVATID: env("LEGAL_VAT_ID", ""),
EditorialResponsibleName: env("EDITORIAL_RESPONSIBLE_NAME", ""),
EditorialResponsibleAddress: env("EDITORIAL_RESPONSIBLE_ADDRESS", ""),
DataProtectionContact: env("DATA_PROTECTION_CONTACT", ""),
HostingProvider: env("HOSTING_PROVIDER", ""),
HostingAddress: env("HOSTING_ADDRESS", ""),
LogRetention: env("LOG_RETENTION", ""),
DataRecipients: env("DATA_RECIPIENTS", ""),
ThirdCountryTransfers: env("THIRD_COUNTRY_TRANSFERS", ""),
SupervisoryAuthorityName: env("SUPERVISORY_AUTHORITY_NAME", ""),
SupervisoryAuthorityURL: env("SUPERVISORY_AUTHORITY_URL", ""),
ConsumerDisputeStatus: strings.ToLower(env("CONSUMER_DISPUTE_STATUS", "")),
ConsumerDisputeBody: env("CONSUMER_DISPUTE_BODY", ""),
ConsumerDisputeURL: env("CONSUMER_DISPUTE_URL", ""),
AccessibilityContact: env("ACCESSIBILITY_CONTACT", ""),
AccessibilityStatus: env("ACCESSIBILITY_STATUS", ""),
LicenseToken: secretEnv("LICENSE_TOKEN"),
LicenseMode: mode,
@@ -62,6 +137,115 @@ func ConfigFromEnv() Config {
}
}
func validateConfig(cfg Config) error {
if len(cfg.EnvironmentErrors) > 0 {
return fmt.Errorf("invalid environment configuration: %s", strings.Join(cfg.EnvironmentErrors, "; "))
}
u, err := url.Parse(cfg.BaseURL)
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("BASE_URL must be an absolute http(s) origin without path, query, credentials or fragment")
}
if strings.TrimSpace(cfg.PublicName) == "" {
return errors.New("PUBLIC_NAME must not be empty")
}
if cfg.TrustProxy && len(cfg.TrustedProxies) == 0 {
return errors.New("TRUST_PROXY=true requires at least one TRUSTED_PROXY_CIDRS entry")
}
if cfg.MetricsEnabled && configValueMissing(cfg.MetricsToken) {
return errors.New("METRICS_ENABLED=true requires METRICS_TOKEN or METRICS_TOKEN_FILE")
}
for _, item := range []struct {
name, value string
}{
{"CONTACT_URL", cfg.ContactURL},
{"SALES_URL", cfg.SalesURL},
{"SUPERVISORY_AUTHORITY_URL", cfg.SupervisoryAuthorityURL},
{"CONSUMER_DISPUTE_URL", cfg.ConsumerDisputeURL},
} {
if err := validateOptionalHTTPURL(item.name, item.value); err != nil {
return err
}
}
for _, item := range []struct {
name, value string
}{
{"LEGAL_EMAIL", cfg.LegalEmail},
{"DATA_PROTECTION_CONTACT", cfg.DataProtectionContact},
{"ACCESSIBILITY_CONTACT", cfg.AccessibilityContact},
} {
if err := validateOptionalEmail(item.name, item.value); err != nil {
return err
}
}
if cfg.LegalStrict {
missing := legalRequiredFields(cfg)
if len(missing) > 0 {
return fmt.Errorf("LEGAL_STRICT=true but required legal configuration is missing: %s", strings.Join(missing, ", "))
}
}
switch cfg.ConsumerDisputeStatus {
case "", "not_applicable", "not_participating", "participating":
default:
return errors.New("CONSUMER_DISPUTE_STATUS must be empty, not_applicable, not_participating or participating")
}
if cfg.ConsumerDisputeStatus == "participating" && (cfg.ConsumerDisputeBody == "" || cfg.ConsumerDisputeURL == "") {
return errors.New("CONSUMER_DISPUTE_BODY and CONSUMER_DISPUTE_URL are required when CONSUMER_DISPUTE_STATUS=participating")
}
return nil
}
func legalRequiredFields(cfg Config) []string {
var missing []string
for _, field := range []struct{ key, value string }{
{"LEGAL_NAME", cfg.LegalName},
{"LEGAL_ADDRESS", cfg.LegalAddress},
{"LEGAL_EMAIL", cfg.LegalEmail},
} {
if configValueMissing(field.value) {
missing = append(missing, field.key)
}
}
if configValueMissing(cfg.LogRetention) {
missing = append(missing, "LOG_RETENTION")
}
if configValueMissing(cfg.HostingProvider) {
missing = append(missing, "HOSTING_PROVIDER")
}
if configValueMissing(cfg.ConsumerDisputeStatus) {
missing = append(missing, "CONSUMER_DISPUTE_STATUS")
}
return missing
}
func configValueMissing(value string) bool {
value = strings.ToUpper(strings.TrimSpace(value))
return value == "" || value == "REPLACE_ME" || value == "CHANGEME" || value == "TODO"
}
func validateOptionalHTTPURL(name, value string) error {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
u, err := url.Parse(value)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
return fmt.Errorf("%s must be an absolute http(s) URL without credentials", name)
}
return nil
}
func validateOptionalEmail(name, value string) error {
value = strings.TrimSpace(value)
if value == "" || configValueMissing(value) {
return nil
}
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value {
return fmt.Errorf("%s must be a plain valid email address", name)
}
return nil
}
func env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
@@ -69,6 +253,19 @@ func env(key, fallback string) string {
return fallback
}
func boolEnv(key string, fallback bool, environmentErrors *[]string) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
*environmentErrors = append(*environmentErrors, key+" must be true or false")
return fallback
}
return parsed
}
func secretEnv(key string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
@@ -84,6 +281,23 @@ func secretEnv(key string) string {
return strings.TrimSpace(string(data))
}
func prefixListEnv(key string, environmentErrors *[]string) []netip.Prefix {
var out []netip.Prefix
for _, raw := range strings.Split(os.Getenv(key), ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
prefix, err := netip.ParsePrefix(raw)
if err != nil {
*environmentErrors = append(*environmentErrors, key+" contains invalid CIDR "+raw)
continue
}
out = append(out, prefix.Masked())
}
return out
}
func durationEnv(key string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
+343
View File
@@ -0,0 +1,343 @@
package app
import (
"fmt"
"sort"
"strings"
)
type legalNav struct {
Imprint string
Privacy string
Accessibility string
}
type legalField struct {
Label string
Value string
URL string
}
type legalSection struct {
Title string
Paragraphs []string
Fields []legalField
Bullets []string
}
type legalPage struct {
Title string
MetaDescription string
Eyebrow string
Intro string
Warning string
Sections []legalSection
LastUpdated string
}
func legalNavFor(lang string) legalNav {
if lang == "de" {
return legalNav{Imprint: "Impressum", Privacy: "Datenschutz", Accessibility: "Barrierefreiheit"}
}
return legalNav{Imprint: "Legal notice", Privacy: "Privacy", Accessibility: "Accessibility"}
}
func imprintPage(cfg Config, lang string) legalPage {
missing := missingNamed(cfg, map[string]string{
"LEGAL_NAME": cfg.LegalName, "LEGAL_ADDRESS": cfg.LegalAddress, "LEGAL_EMAIL": cfg.LegalEmail,
"CONSUMER_DISPUTE_STATUS": cfg.ConsumerDisputeStatus,
})
if lang == "de" {
sections := []legalSection{
{
Title: "Anbieter und Kontakt",
Fields: compactFields([]legalField{
{Label: "Name / Firma", Value: requiredValue(cfg.LegalName)},
{Label: "Ladungsfähige Anschrift", Value: requiredValue(cfg.LegalAddress)},
{Label: "Vertretungsberechtigt", Value: cfg.LegalRepresentative},
{Label: "E-Mail", Value: requiredValue(cfg.LegalEmail), URL: mailto(cfg.LegalEmail)},
{Label: "Telefon", Value: cfg.LegalPhone, URL: tel(cfg.LegalPhone)},
}),
},
{
Title: "Register und Steuerangaben",
Fields: compactFields([]legalField{
{Label: "Registergericht / Register", Value: cfg.LegalRegister},
{Label: "Registernummer", Value: cfg.LegalRegisterNumber},
{Label: "Umsatzsteuer-ID", Value: cfg.LegalVATID},
}),
Paragraphs: []string{"Nur tatsächlich einschlägige Register-, Aufsichts- und Steuerangaben eintragen. Berufsrechtliche Pflichtangaben müssen bei reglementierten Berufen zusätzlich ergänzt werden."},
},
}
if cfg.EditorialResponsibleName != "" || cfg.EditorialResponsibleAddress != "" {
sections = append(sections, legalSection{
Title: "Inhaltlich verantwortlich",
Paragraphs: []string{"Verantwortlich für journalistisch-redaktionelle Inhalte, soweit § 18 Abs. 2 Medienstaatsvertrag anwendbar ist:"},
Fields: compactFields([]legalField{{Label: "Name", Value: cfg.EditorialResponsibleName}, {Label: "Anschrift", Value: cfg.EditorialResponsibleAddress}}),
})
}
if dispute := disputeSection(cfg, true); dispute.Title != "" {
sections = append(sections, dispute)
}
sections = append(sections,
legalSection{
Title: "Hinweise zu Inhalten und Kennzeichnungen",
Paragraphs: []string{
"Die Anwendung erzeugt technische Transparenzhinweise auf Grundlage der eingegebenen Angaben. Sie prüft nicht automatisch, ob eine konkrete Kennzeichnung gesetzlich erforderlich, vollständig oder inhaltlich richtig ist.",
"Für veröffentlichte Erklärungen, redaktionelle Inhalte und eingebundene Links bleibt die jeweils veröffentlichende Person oder Organisation verantwortlich.",
},
},
legalSection{
Title: "Rechtsgrundlagen der Anbieterkennzeichnung",
Paragraphs: []string{"Je nach Angebot können insbesondere § 5 Digitale-Dienste-Gesetz und § 18 Medienstaatsvertrag einschlägig sein. Die konkrete Pflicht hängt vom Betreiber, Geschäftsmodell und Inhalt ab."},
Fields: []legalField{
{Label: "§ 5 DDG", Value: "Allgemeine Informationspflichten", URL: "https://www.gesetze-im-internet.de/ddg/__5.html"},
{Label: "§ 18 MStV", Value: "Informationspflichten und Auskunftsrechte", URL: "https://www.gesetze-bayern.de/Content/Document/MStV-18"},
},
},
)
return legalPage{
Title: "Impressum", MetaDescription: "Anbieterkennzeichnung und Kontaktangaben.", Eyebrow: "Rechtliche Informationen",
Intro: "Betreiberangaben für diesen digitalen Dienst. Platzhalter müssen vor dem öffentlichen Produktivbetrieb durch zutreffende Angaben ersetzt werden.",
Warning: legalWarning(missing, true), Sections: sections, LastUpdated: "20. Juli 2026",
}
}
sections := []legalSection{
{Title: "Provider and contact", Fields: compactFields([]legalField{
{Label: "Name / company", Value: requiredValue(cfg.LegalName)},
{Label: "Service address", Value: requiredValue(cfg.LegalAddress)},
{Label: "Represented by", Value: cfg.LegalRepresentative},
{Label: "Email", Value: requiredValue(cfg.LegalEmail), URL: mailto(cfg.LegalEmail)},
{Label: "Telephone", Value: cfg.LegalPhone, URL: tel(cfg.LegalPhone)},
})},
{Title: "Registration and tax details", Fields: compactFields([]legalField{
{Label: "Register / court", Value: cfg.LegalRegister}, {Label: "Registration number", Value: cfg.LegalRegisterNumber}, {Label: "VAT ID", Value: cfg.LegalVATID},
}), Paragraphs: []string{"Only include registration, supervisory and professional details that actually apply to the operator."}},
}
if cfg.EditorialResponsibleName != "" || cfg.EditorialResponsibleAddress != "" {
sections = append(sections, legalSection{Title: "Editorial responsibility", Paragraphs: []string{"Responsible for journalistic-editorial content where applicable under German media law:"}, Fields: compactFields([]legalField{{Label: "Name", Value: cfg.EditorialResponsibleName}, {Label: "Address", Value: cfg.EditorialResponsibleAddress}})})
}
if dispute := disputeSection(cfg, false); dispute.Title != "" {
sections = append(sections, dispute)
}
sections = append(sections, legalSection{Title: "Content and disclosure notices", Paragraphs: []string{
"The service creates technical transparency notices from the information supplied. It does not automatically determine whether a particular disclosure is legally required, complete or accurate.",
"The publishing person or organisation remains responsible for declarations, editorial content and linked material.",
}})
return legalPage{Title: "Legal notice", MetaDescription: "Provider identification and contact details.", Eyebrow: "Legal information", Intro: "Operator information for this digital service. Replace all placeholders with accurate details before public production use.", Warning: legalWarning(missing, false), Sections: sections, LastUpdated: "20 July 2026"}
}
func privacyPage(cfg Config, lang string) legalPage {
contact := firstNonEmpty(cfg.DataProtectionContact, cfg.LegalEmail)
missing := missingNamed(cfg, map[string]string{
"LEGAL_NAME": cfg.LegalName, "LEGAL_ADDRESS": cfg.LegalAddress, "LEGAL_EMAIL": cfg.LegalEmail,
"HOSTING_PROVIDER": cfg.HostingProvider, "LOG_RETENTION": cfg.LogRetention,
})
if lang == "de" {
sections := []legalSection{
{
Title: "1. Verantwortlicher",
Fields: compactFields([]legalField{
{Label: "Verantwortlicher", Value: requiredValue(cfg.LegalName)},
{Label: "Anschrift", Value: requiredValue(cfg.LegalAddress)},
{Label: "Datenschutzkontakt", Value: requiredValue(contact), URL: mailto(contact)},
}),
},
{
Title: "2. Aufruf der Website und Server-Protokolle",
Paragraphs: []string{
"Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet. Dazu können IP-Adresse, Zeitpunkt, angeforderter Pfad, HTTP-Methode, Statuscode, übertragene Datenmenge, Referrer und User-Agent gehören. Welche Daten der vorgeschaltete Hosting- oder Proxy-Dienst tatsächlich protokolliert, muss der Betreiber anhand seiner Infrastruktur prüfen.",
"Die Anwendung selbst protokolliert standardmäßig Methode, Pfad ohne Query-String, Statuscode, Datenmenge, Dauer und eine Request-ID. Die Client-IP wird nur protokolliert, wenn LOG_CLIENT_IP ausdrücklich aktiviert ist.",
"Zwecke sind die sichere und stabile Bereitstellung, Fehleranalyse und Missbrauchsabwehr. Rechtsgrundlage ist regelmäßig Art. 6 Abs. 1 lit. f DSGVO; bei vertraglicher Nutzung kann zusätzlich Art. 6 Abs. 1 lit. b DSGVO einschlägig sein.",
},
Fields: []legalField{{Label: "Vorgesehene Aufbewahrungsdauer", Value: requiredValue(cfg.LogRetention)}},
},
{
Title: "3. Eingaben, URLs und öffentliche Erklärungen",
Paragraphs: []string{
"Der Generator arbeitet zustandslos und speichert Eingaben nicht in einer Anwendungsdatenbank. Angaben werden jedoch als URL-Parameter verarbeitet und können dadurch im Browser-Verlauf, in Proxy- oder Zugriffsprotokollen sowie bei Weitergabe des erzeugten Links sichtbar werden.",
"Erzeugte Erklärungs- und JSON-LD-URLs sind zur öffentlichen Einbindung bestimmt. Deshalb dürfen keine vertraulichen Informationen, besonderen Kategorien personenbezogener Daten oder unnötigen personenbezogenen Angaben in Freitextfelder und URLs eingetragen werden.",
},
},
{
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.",
},
},
{
Title: "5. Hosting und Empfänger",
Fields: compactFields([]legalField{
{Label: "Hosting-Anbieter", Value: requiredValue(cfg.HostingProvider)},
{Label: "Anschrift / Region", Value: cfg.HostingAddress},
{Label: "Weitere Empfänger", Value: cfg.DataRecipients},
{Label: "Drittlandübermittlungen", Value: cfg.ThirdCountryTransfers},
}),
Paragraphs: []string{"Mit Hosting- und sonstigen Auftragsverarbeitern sind, soweit erforderlich, Verträge nach Art. 28 DSGVO abzuschließen. Übermittlungen in Drittländer dürfen nur auf einer tragfähigen Rechtsgrundlage erfolgen."},
},
{
Title: "6. Optionale Lizenzprüfung",
Paragraphs: []string{
"Im Offline-Modus findet keine Online-Lizenzprüfung statt. Im Hybrid- oder Online-Modus übermittelt der Lizenz-Client an den konfigurierten Lizenzserver insbesondere Lizenz-Token, Produktkennung, öffentliche Basis-URL, Host, optionale Instanz-ID und Client-Version. Betreiber müssen den eingesetzten Lizenzserver, die Rollenverteilung, Speicherdauer und Rechtsgrundlage gesondert dokumentieren.",
},
},
{
Title: "7. Rechte betroffener Personen",
Paragraphs: []string{"Betroffene Personen haben nach Maßgabe der DSGVO insbesondere Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Erteilte Einwilligungen können mit Wirkung für die Zukunft widerrufen werden. Außerdem besteht ein Beschwerderecht bei einer Datenschutzaufsichtsbehörde."},
Fields: compactFields([]legalField{{Label: "Zuständige oder benannte Aufsichtsbehörde", Value: cfg.SupervisoryAuthorityName, URL: cfg.SupervisoryAuthorityURL}}),
},
{
Title: "8. Sicherheit und Änderungen",
Paragraphs: []string{
"Die Anwendung verwendet unter anderem restriktive Browser-Sicherheitsrichtlinien, Größenlimits für JSON-Anfragen, sichere Standard-Containeroptionen und minimierte Protokollierung. TLS/HTTPS, Backups, Zugriffsschutz, Patch-Management, Monitoring und Löschfristen müssen zusätzlich in der Betriebsumgebung umgesetzt werden.",
"Diese Hinweise sind anzupassen, sobald Funktionen, Empfänger, Hosting, Protokollierung oder Rechtsgrundlagen geändert werden.",
},
},
}
return legalPage{Title: "Datenschutzerklärung", MetaDescription: "Informationen zur Verarbeitung personenbezogener Daten.", Eyebrow: "Datenschutz", Intro: "Diese Vorlage beschreibt den technischen Standardzustand der Anwendung. Der tatsächliche Betreiber muss sie an Hosting, Logs, Verträge und Zusatzdienste anpassen.", Warning: legalWarning(missing, true), Sections: sections, LastUpdated: "20. Juli 2026"}
}
sections := []legalSection{
{Title: "1. Controller", Fields: compactFields([]legalField{{Label: "Controller", Value: requiredValue(cfg.LegalName)}, {Label: "Address", Value: requiredValue(cfg.LegalAddress)}, {Label: "Privacy contact", Value: requiredValue(contact), URL: mailto(contact)}})},
{Title: "2. Website access and server logs", Paragraphs: []string{
"Technically necessary connection data may be processed when the service is accessed, including IP address, time, requested path, HTTP method, status, transferred bytes, referrer and user agent. The operator must verify the exact logging performed by its hosting and proxy infrastructure.",
"By default, the application logs method, path without query string, status, bytes, duration and a request ID. Client IP logging is disabled unless LOG_CLIENT_IP is explicitly enabled.",
"Purposes are secure and reliable operation, troubleshooting and abuse prevention. The legal basis is generally Article 6(1)(f) GDPR and, where relevant, Article 6(1)(b) GDPR.",
}, Fields: []legalField{{Label: "Intended retention period", Value: requiredValue(cfg.LogRetention)}}},
{Title: "3. Inputs, URLs and public declarations", Paragraphs: []string{
"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: "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}})},
{Title: "8. Security and changes", Paragraphs: []string{"The application includes restrictive browser policies, JSON request-size limits, hardened container defaults and minimised logging. TLS, backups, access controls, patching, monitoring and deletion schedules must also be implemented in the operating environment.", "Update this notice whenever functions, recipients, hosting or logging change."}},
}
return legalPage{Title: "Privacy notice", MetaDescription: "Information about personal-data processing.", Eyebrow: "Privacy", Intro: "This template describes the application's default technical behaviour. The actual operator must adapt it to hosting, logs, contracts and added services.", Warning: legalWarning(missing, false), Sections: sections, LastUpdated: "20 July 2026"}
}
func accessibilityPage(cfg Config, lang string) legalPage {
contact := firstNonEmpty(cfg.AccessibilityContact, cfg.LegalEmail)
missing := missingNamed(cfg, map[string]string{"ACCESSIBILITY_CONTACT or LEGAL_EMAIL": contact})
if lang == "de" {
status := cfg.AccessibilityStatus
if status == "" {
status = "Noch nicht durch eine unabhängige Stelle geprüft; derzeit keine vollständige Konformitätserklärung."
}
return legalPage{
Title: "Barrierefreiheit", MetaDescription: "Hinweise zur barrierefreien Nutzung und Kontakt für Rückmeldungen.", Eyebrow: "Zugänglichkeit",
Intro: "Wir möchten, dass der Generator, die Erklärungsseiten und die Rechtstexte möglichst vielen Menschen zugänglich sind.",
Warning: legalWarning(missing, true), LastUpdated: "20. Juli 2026",
Sections: []legalSection{
{Title: "Stand der Vereinbarkeit", Paragraphs: []string{status, "Ob das Barrierefreiheitsstärkungsgesetz oder andere verbindliche Anforderungen auf ein konkretes Angebot anwendbar sind, muss der jeweilige Betreiber anhand seines Geschäftsmodells und seiner Zielgruppe prüfen."}},
{Title: "Umgesetzte Maßnahmen", Bullets: []string{
"semantische Überschriften, Beschriftungen und Tabellenstrukturen",
"Bedienbarkeit der Kernfunktionen per Tastatur",
"sichtbare Fokusmarkierungen und responsive Darstellung",
"Textalternativen für erzeugte Badge-Vorschauen",
"keine zwingenden Animationen, Cookies oder Tracking-Dialoge",
}},
{Title: "Bekannte Grenzen", Bullets: []string{
"Es liegt noch kein vollständiger Audit nach EN 301 549 oder WCAG 2.2 vor.",
"Breite Datentabellen können auf kleinen Bildschirmen horizontales Scrollen erfordern.",
"Die Verständlichkeit automatisch erzeugter Erklärungen hängt von den eingegebenen Angaben ab.",
"Eingebettete Badges und Erklärungen müssen auch auf der einbindenden Website barrierefrei beschriftet und positioniert werden.",
}},
{Title: "Rückmeldung und Kontakt", Paragraphs: []string{"Bitte melden Sie Barrieren mit URL, Gerät, Browser, assistiver Technologie und einer kurzen Beschreibung. Wir prüfen nachvollziehbare Hinweise und bemühen uns um eine geeignete Lösung."}, Fields: []legalField{{Label: "Kontakt", Value: requiredValue(contact), URL: mailto(contact)}}},
{Title: "Rechtlicher Kontext", Paragraphs: []string{"Das BFSG gilt seit dem 28. Juni 2025 für bestimmte Produkte und Dienstleistungen für Verbraucher. Nicht jede Website fällt automatisch darunter. Für erfasste Angebote sind die gesetzlichen Informations- und Barrierefreiheitsanforderungen gesondert zu erfüllen."}, Fields: []legalField{{Label: "Bundesfachstelle Barrierefreiheit", Value: "Informationen zum BFSG", URL: "https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz"}}},
},
}
}
status := cfg.AccessibilityStatus
if status == "" {
status = "Not yet independently audited; no claim of full conformance is made."
}
return legalPage{
Title: "Accessibility", MetaDescription: "Accessibility status and feedback contact.", Eyebrow: "Accessibility",
Intro: "We aim to make the generator, declaration pages and legal information accessible to as many people as possible.",
Warning: legalWarning(missing, false), LastUpdated: "20 July 2026",
Sections: []legalSection{
{Title: "Conformance status", Paragraphs: []string{status, "The operator must assess whether the German Accessibility Strengthening Act or other binding rules apply to its specific offering."}},
{Title: "Measures implemented", Bullets: []string{"semantic headings, labels and table structures", "keyboard access to core functions", "visible focus indicators and responsive layout", "text alternatives for generated badge previews", "no mandatory animations, cookie or tracking dialogs"}},
{Title: "Known limitations", Bullets: []string{"No complete EN 301 549 or WCAG 2.2 audit has yet been completed.", "Wide data tables may require horizontal scrolling on small screens.", "The clarity of generated declarations depends on the information entered.", "Embedded badges must also be labelled and positioned accessibly on the host website."}},
{Title: "Feedback", Paragraphs: []string{"Please report barriers with the URL, device, browser, assistive technology and a brief description."}, Fields: []legalField{{Label: "Contact", Value: requiredValue(contact), URL: mailto(contact)}}},
},
}
}
func disputeSection(cfg Config, german bool) legalSection {
switch cfg.ConsumerDisputeStatus {
case "not_applicable":
return legalSection{}
case "not_participating":
if german {
return legalSection{Title: "Verbraucherstreitbeilegung", Paragraphs: []string{"Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."}}
}
return legalSection{Title: "Consumer dispute resolution", Paragraphs: []string{"We are neither willing nor obliged to participate in dispute-resolution proceedings before a consumer arbitration body."}}
case "participating":
if german {
return legalSection{Title: "Verbraucherstreitbeilegung", Paragraphs: []string{"Wir nehmen an Streitbeilegungsverfahren vor der folgenden Verbraucherschlichtungsstelle teil:"}, Fields: []legalField{{Label: "Schlichtungsstelle", Value: cfg.ConsumerDisputeBody, URL: cfg.ConsumerDisputeURL}}}
}
return legalSection{Title: "Consumer dispute resolution", Paragraphs: []string{"We participate in dispute-resolution proceedings before the following consumer arbitration body:"}, Fields: []legalField{{Label: "Arbitration body", Value: cfg.ConsumerDisputeBody, URL: cfg.ConsumerDisputeURL}}}
default:
return legalSection{}
}
}
func requiredValue(value string) string {
if configValueMissing(value) {
return "[NICHT KONFIGURIERT / NOT CONFIGURED]"
}
return value
}
func compactFields(in []legalField) []legalField {
out := make([]legalField, 0, len(in))
for _, field := range in {
if strings.TrimSpace(field.Value) != "" {
out = append(out, field)
}
}
return out
}
func missingNamed(cfg Config, fields map[string]string) []string {
_ = cfg
var missing []string
for key, value := range fields {
if configValueMissing(value) {
missing = append(missing, key)
}
}
sort.Strings(missing)
return missing
}
func legalWarning(missing []string, german bool) string {
if len(missing) == 0 {
return ""
}
if german {
return fmt.Sprintf("Diese Seite ist noch nicht produktionsreif. Folgende Betreiberangaben fehlen: %s.", strings.Join(missing, ", "))
}
return fmt.Sprintf("This page is not ready for production. Missing operator configuration: %s.", strings.Join(missing, ", "))
}
func mailto(value string) string {
value = strings.TrimSpace(value)
if value == "" || !strings.Contains(value, "@") || strings.ContainsAny(value, "\r\n") {
return ""
}
return "mailto:" + value
}
func tel(value string) string {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, "\r\n") {
return ""
}
replacer := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "", "/", "")
return "tel:" + replacer.Replace(value)
}
+196 -26
View File
@@ -1,8 +1,11 @@
package app
import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -12,6 +15,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"net/netip"
"net/url"
"runtime/debug"
"sort"
@@ -28,12 +32,13 @@ import (
)
type Server struct {
cfg Config
logger *slog.Logger
templates *template.Template
metrics *metrics
licenses *licenseclient.Client
mux *http.ServeMux
cfg Config
logger *slog.Logger
templates *template.Template
metrics *metrics
licenses *licenseclient.Client
mux *http.ServeMux
validateSem chan struct{}
}
type option struct{ Value, Label string }
@@ -75,6 +80,9 @@ type pageData struct {
Background background.Page
LanguageLinks []languageLink
DefaultLanguageURL string
LegalNav legalNav
Legal legalPage
CSPNonce string
}
type clientConfig struct {
@@ -84,7 +92,18 @@ type clientConfig struct {
Capabilities map[string]bool `json:"capabilities"`
}
type publicLicenseStatus struct {
Edition string `json:"edition"`
Licensed bool `json:"licensed"`
Features []string `json:"features"`
Limits map[string]int64 `json:"limits,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
}
func New(ctx context.Context, cfg Config, logger *slog.Logger) (http.Handler, error) {
if err := validateConfig(cfg); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
tmpl, err := template.New("root").ParseFS(webassets.Files, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
@@ -100,7 +119,7 @@ func New(ctx context.Context, cfg Config, logger *slog.Logger) (http.Handler, er
RequestTimeout: cfg.LicenseTimeout, ClientVersion: ProductVersion,
})
licenses.Start(ctx)
s := &Server{cfg: cfg, logger: logger, templates: tmpl, metrics: newMetrics(), licenses: licenses, mux: http.NewServeMux()}
s := &Server{cfg: cfg, logger: logger, templates: tmpl, metrics: newMetrics(), licenses: licenses, mux: http.NewServeMux(), validateSem: make(chan struct{}, 32)}
s.routes()
return s.middleware(s.mux), nil
}
@@ -111,6 +130,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /", s.handleIndex)
s.mux.HandleFunc("GET /product", s.handleMarketing)
s.mux.HandleFunc("GET /background", s.handleBackground)
s.mux.HandleFunc("GET /impressum", s.handleImprint)
s.mux.HandleFunc("GET /datenschutz", s.handlePrivacy)
s.mux.HandleFunc("GET /barrierefreiheit", s.handleAccessibility)
s.mux.HandleFunc("GET /install", s.handleMarketingAlias)
s.mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(fileServer)))
s.mux.HandleFunc("GET /badge/{file}", s.handlePresetBadge)
@@ -123,7 +145,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /context/v1", s.handleContext)
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("GET /readyz", s.handleReady)
s.mux.HandleFunc("GET /metrics", s.metrics.serveHTTP)
if s.cfg.MetricsEnabled {
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
}
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -146,7 +170,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
Languages: i18n.Languages(), Presets: presetOptions(locale), Components: orderedOptions(locale.Components, []string{"text", "coverImage", "image", "audio", "video", "code", "other"}),
Extents: orderedOptions(locale.Extents, []string{"assisted", "none", "partial", "mostly", "full"}), Reviews: orderedOptions(locale.Reviews, []string{"editorial", "expert", "basic", "none"}),
Assurances: orderedOptions(locale.Assurances, []string{"selfDeclared", "technicallyRecorded", "signed", "verified"}),
AppConfig: template.JS(appJSON), License: s.licenses.Status(), CustomText: s.licenses.Has(FeatureCustomText), CustomBadge: s.licenses.Has(FeatureCustomBadge),
AppConfig: template.JS(appJSON), LegalNav: legalNavFor(lang), License: s.licenses.Status(), CustomText: s.licenses.Has(FeatureCustomText), CustomBadge: s.licenses.Has(FeatureCustomBadge),
}
s.renderHTML(w, "index.html", data)
}
@@ -161,11 +185,38 @@ func (s *Server) handleBackground(w http.ResponseWriter, r *http.Request) {
data := pageData{
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL,
Lang: lang, Languages: i18n.Languages(), Background: background.Build(lang),
License: s.licenses.Status(), LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL,
License: s.licenses.Status(), LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL, LegalNav: legalNavFor(lang),
}
s.renderHTML(w, "background.html", data)
}
func (s *Server) handleImprint(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/impressum" {
http.NotFound(w, r)
return
}
lang := s.language(r)
s.renderHTML(w, "legal.html", pageData{Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, CanonicalURL: s.cfg.BaseURL + "/impressum", Lang: lang, LegalNav: legalNavFor(lang), Legal: imprintPage(s.cfg, lang)})
}
func (s *Server) handlePrivacy(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/datenschutz" {
http.NotFound(w, r)
return
}
lang := s.language(r)
s.renderHTML(w, "legal.html", pageData{Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, CanonicalURL: s.cfg.BaseURL + "/datenschutz", Lang: lang, LegalNav: legalNavFor(lang), Legal: privacyPage(s.cfg, lang)})
}
func (s *Server) handleAccessibility(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/barrierefreiheit" {
http.NotFound(w, r)
return
}
lang := s.language(r)
s.renderHTML(w, "legal.html", pageData{Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, CanonicalURL: s.cfg.BaseURL + "/barrierefreiheit", Lang: lang, LegalNav: legalNavFor(lang), Legal: accessibilityPage(s.cfg, lang)})
}
func (s *Server) staticLanguageLinks(path, current string) ([]languageLink, string) {
links := make([]languageLink, 0, len(i18n.Languages()))
for _, language := range i18n.Languages() {
@@ -193,7 +244,7 @@ func (s *Server) handleMarketing(w http.ResponseWriter, r *http.Request) {
})
data := pageData{
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL,
Lang: lang, Languages: i18n.Languages(), Marketing: page, License: s.licenses.Status(),
Lang: lang, Languages: i18n.Languages(), Marketing: page, License: s.licenses.Status(), LegalNav: legalNavFor(lang),
}
s.renderHTML(w, "marketing.html", data)
}
@@ -303,7 +354,8 @@ func (s *Server) renderBadge(w http.ResponseWriter, r *http.Request, q url.Value
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
w.Header().Set("ETag", etag)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
_, _ = w.Write(data)
}
@@ -349,7 +401,7 @@ func (s *Server) handleDeclaration(w http.ResponseWriter, r *http.Request) {
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: d.Language, Text: locale.Text, Languages: i18n.Languages(),
Declaration: d, Title: title, Description: description, BadgeURL: badgeURL, ManifestURL: manifestURL, CanonicalURL: canonicalURL, JSONLD: template.JS(jsonLD),
Facts: declarationFacts(d, locale), ComponentRows: declarationComponentRows(d, locale), Summary: declarationSummary(d, locale), IsArticle: isArticle, License: s.licenses.Status(),
LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL,
LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL, LegalNav: legalNavFor(d.Language),
}
w.Header().Set("Link", "<"+manifestURL+">; rel=describedby; type=application/ld+json")
s.renderHTML(w, "declaration.html", data)
@@ -386,6 +438,7 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/ld+json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
_ = json.NewEncoder(w).Encode(d)
}
@@ -405,6 +458,19 @@ func (s *Server) declarationError(w http.ResponseWriter, err error) {
func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
s.metrics.validationRequests.Add(1)
if contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0])); contentType != "" && contentType != "application/json" {
s.metrics.validationFailures.Add(1)
s.problem(w, http.StatusUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json.")
return
}
select {
case s.validateSem <- struct{}{}:
defer func() { <-s.validateSem }()
default:
w.Header().Set("Retry-After", "1")
s.problem(w, http.StatusServiceUnavailable, "validation_busy", "Too many concurrent validation requests.")
return
}
body := http.MaxBytesReader(w, r.Body, 1<<20)
defer body.Close()
dec := json.NewDecoder(body)
@@ -412,6 +478,11 @@ func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
var d declaration.Declaration
if err := dec.Decode(&d); err != nil {
s.metrics.validationFailures.Add(1)
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
s.problem(w, http.StatusRequestEntityTooLarge, "request_too_large", "JSON request body exceeds 1 MiB.")
return
}
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
@@ -428,14 +499,34 @@ func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
s.writeJSON(w, http.StatusOK, map[string]any{"valid": true, "schemaVersion": d.SchemaVersion})
}
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if token := s.cfg.MetricsToken; token != "" {
provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(provided) != len(token) || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
s.metrics.serveHTTP(w, r)
}
func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
s.writeJSON(w, http.StatusOK, map[string]any{"license": s.licenses.Status(), "supportedLanguages": languageCodes()})
s.writeJSON(w, http.StatusOK, map[string]any{"license": publicLicense(s.licenses.Status()), "supportedLanguages": languageCodes()})
}
func publicLicense(status licenseclient.Status) publicLicenseStatus {
return publicLicenseStatus{
Edition: status.Edition, Licensed: status.Licensed, Features: append([]string(nil), status.Features...),
Limits: status.Limits, ExpiresAt: status.ExpiresAt,
}
}
func (s *Server) handleSchema(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/schema+json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
_, _ = w.Write([]byte(strings.ReplaceAll(declarationSchema, "__BASE_URL__", s.cfg.BaseURL)))
}
@@ -443,6 +534,7 @@ func (s *Server) handleContext(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/ld+json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
_, _ = w.Write([]byte(strings.ReplaceAll(jsonLDContext, "__BASE_URL__", s.cfg.BaseURL)))
}
@@ -458,11 +550,24 @@ func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) renderHTML(w http.ResponseWriter, name string, data pageData) {
nonce := randomNonce()
data.CSPNonce = nonce
var buf bytes.Buffer
if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Error("template render failed", "template", name, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
csp := "default-src 'self'; script-src 'self' 'nonce-" + nonce + "'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
if strings.HasPrefix(s.cfg.BaseURL, "https://") {
csp += "; upgrade-insecure-requests"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
s.logger.Error("template render failed", "template", name, "error", err)
}
w.Header().Set("Content-Security-Policy", csp)
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
_, _ = w.Write(buf.Bytes())
}
func (s *Server) language(r *http.Request) string {
@@ -476,8 +581,8 @@ func (s *Server) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
s.metrics.requests.Add(1)
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID := strings.TrimSpace(r.Header.Get("X-Request-ID"))
if !validRequestID(requestID) {
requestID = randomID()
}
w.Header().Set("X-Request-ID", requestID)
@@ -485,7 +590,10 @@ func (s *Server) middleware(next http.Handler) http.Handler {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
if s.cfg.EnableHSTS && strings.HasPrefix(s.cfg.BaseURL, "https://") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/schema/") || strings.HasPrefix(r.URL.Path, "/context/") {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
@@ -502,7 +610,11 @@ func (s *Server) middleware(next http.Handler) http.Handler {
s.logger.Error("handler panic", "request_id", requestID, "panic", recovered, "stack", string(debug.Stack()))
http.Error(rw, "internal server error", http.StatusInternalServerError)
}
s.logger.Info("request", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "status", rw.status, "bytes", rw.bytes, "duration_ms", time.Since(started).Milliseconds(), "remote", clientIP(r, s.cfg.TrustProxy))
attrs := []any{"request_id", requestID, "method", r.Method, "path", r.URL.Path, "status", rw.status, "bytes", rw.bytes, "duration_ms", time.Since(started).Milliseconds()}
if s.cfg.LogClientIP {
attrs = append(attrs, "remote", clientIP(r, s.cfg.TrustProxy, s.cfg.TrustedProxies))
}
s.logger.Info("request", attrs...)
}()
next.ServeHTTP(rw, r)
})
@@ -934,13 +1046,71 @@ func randomID() string {
}
return hex.EncodeToString(b)
}
func clientIP(r *http.Request, trustProxy bool) string {
if trustProxy {
if x := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); x != "" {
return x
func clientIP(r *http.Request, trustProxy bool, trusted []netip.Prefix) string {
remote := strings.TrimSpace(r.RemoteAddr)
var remoteAddr netip.Addr
if addrPort, err := netip.ParseAddrPort(remote); err == nil {
remoteAddr = addrPort.Addr()
} else {
remoteAddr, _ = netip.ParseAddr(strings.Trim(remote, "[]"))
}
if trustProxy && remoteAddr.IsValid() && prefixContains(trusted, remoteAddr) {
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
var forwarded []netip.Addr
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
addr, err := netip.ParseAddr(strings.Trim(part, "[]"))
if err != nil {
return remoteAddr.String()
}
forwarded = append(forwarded, addr.Unmap())
}
for index := len(forwarded) - 1; index >= 0; index-- {
if !prefixContains(trusted, forwarded[index]) {
return forwarded[index].String()
}
}
if len(forwarded) > 0 {
return forwarded[0].String()
}
}
return r.RemoteAddr
if remoteAddr.IsValid() {
return remoteAddr.String()
}
return remote
}
func prefixContains(prefixes []netip.Prefix, addr netip.Addr) bool {
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func validRequestID(value string) bool {
if len(value) < 1 || len(value) > 64 {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("-_.:", r) {
continue
}
return false
}
return true
}
func randomNonce() string {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return randomID()
}
return base64.RawStdEncoding.EncodeToString(b)
}
func ensureEOF(dec *json.Decoder) error {
var extra any
+186
View File
@@ -7,8 +7,11 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"github.com/b1tsblog/license-platform/sdk/go/licenseclient"
)
func testHandler(t *testing.T) http.Handler {
@@ -81,6 +84,30 @@ func TestCapabilities(t *testing.T) {
}
}
func TestPublicLicenseRedactsInternalMetadata(t *testing.T) {
status := publicLicense(licenseclient.Status{
Edition: "pro", Licensed: true, LicenseID: "lic-secret", Customer: "Customer Name", Product: "product",
Features: []string{"custom_text"}, Limits: map[string]int64{"seats": 3}, ExpiresAt: "2030-01-01T00:00:00Z",
Mode: "online", Source: "server", LastChecked: "2026-07-20T12:00:00Z", LeaseExpires: "2026-07-21T12:00:00Z",
Reason: "internal diagnostic", ServerURL: "https://licenses.internal.example",
})
data, err := json.Marshal(status)
if err != nil {
t.Fatal(err)
}
body := string(data)
for _, forbidden := range []string{"lic-secret", "Customer Name", "licenses.internal.example", "internal diagnostic", "lastChecked", "leaseExpiresAt", `"mode"`, `"source"`} {
if strings.Contains(body, forbidden) {
t.Fatalf("public licence status leaks %q: %s", forbidden, body)
}
}
for _, expected := range []string{`"edition":"pro"`, `"licensed":true`, `"custom_text"`, `"seats":3`} {
if !strings.Contains(body, expected) {
t.Fatalf("public licence status missing %q: %s", expected, body)
}
}
}
func TestMarketingPageGerman(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/product?lang=de", nil)
w := httptest.NewRecorder()
@@ -271,3 +298,162 @@ func TestNavigationUsesInternalBackgroundPage(t *testing.T) {
}
}
}
func TestLegalPagesUseConfiguredOperatorData(t *testing.T) {
cfg := Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de",
LegalName: "Beispiel GmbH", LegalAddress: "Musterstraße 1\n10115 Berlin", LegalEmail: "datenschutz@example.org",
HostingProvider: "Beispiel Hosting GmbH", LogRetention: "7 Tage", ConsumerDisputeStatus: "not_participating",
}
h := testHandlerConfig(t, cfg)
for _, tc := range []struct {
path, expected string
}{
{"/impressum?lang=de", "Beispiel GmbH"},
{"/datenschutz?lang=de", "Die Client-IP wird nur protokolliert"},
{"/barrierefreiheit?lang=de", "Noch nicht durch eine unabhängige Stelle geprüft"},
} {
r := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("%s returned %d: %s", tc.path, w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), tc.expected) {
t.Fatalf("%s missing %q", tc.path, tc.expected)
}
if !strings.Contains(w.Body.String(), `/impressum?lang=de`) || !strings.Contains(w.Body.String(), `/datenschutz?lang=de`) {
t.Fatalf("%s missing legal footer links", tc.path)
}
}
}
func TestLegalStrictRejectsMissingConfiguration(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", LegalStrict: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "LEGAL_NAME") {
t.Fatalf("expected strict legal configuration error, got %v", err)
}
}
func TestSecurityHeadersAndRequestIDValidation(t *testing.T) {
cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", EnableHSTS: true}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-Request-ID", "invalid request id\n")
w := httptest.NewRecorder()
testHandlerConfig(t, cfg).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
csp := w.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "script-src 'self' 'nonce-") || strings.Contains(csp, "'unsafe-inline'") {
t.Fatalf("unexpected CSP %q", csp)
}
if got := w.Header().Get("Strict-Transport-Security"); !strings.Contains(got, "max-age=") || strings.Contains(strings.ToLower(got), "includesubdomains") {
t.Fatalf("unexpected HSTS: %q", got)
}
if got := w.Header().Get("X-Request-ID"); got == "" || strings.Contains(got, " ") {
t.Fatalf("invalid response request id %q", got)
}
}
func TestMetricsDisabledByDefaultAndTokenProtected(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/metrics", nil)
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("disabled metrics returned %d", w.Code)
}
cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true, MetricsToken: "a-long-random-test-token"}
h := testHandlerConfig(t, cfg)
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("unprotected metrics returned %d", w.Code)
}
r = httptest.NewRequest(http.MethodGet, "/metrics", nil)
r.Header.Set("Authorization", "Bearer a-long-random-test-token")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "ai_disclosure_http_requests_total") {
t.Fatalf("protected metrics returned %d: %s", w.Code, w.Body.String())
}
}
func TestMetricsCannotBeEnabledWithoutToken(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "METRICS_TOKEN") {
t.Fatalf("expected metrics token validation error, got %v", err)
}
}
func TestInvalidBooleanEnvironmentValueFailsClosed(t *testing.T) {
t.Setenv("LEGAL_STRICT", "definitely")
cfg := ConfigFromEnv()
_, err := New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "LEGAL_STRICT must be true or false") {
t.Fatalf("expected environment validation error, got %v", err)
}
}
func TestLegalEmailMustBePlainAddress(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", LegalEmail: "Name <legal@example.org>",
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "LEGAL_EMAIL") {
t.Fatalf("expected legal email validation error, got %v", err)
}
}
func TestExternalURLsMustUseHTTPOrHTTPS(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", ContactURL: "javascript:alert(1)",
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "CONTACT_URL") {
t.Fatalf("expected contact URL validation error, got %v", err)
}
}
func TestValidateRejectsOversizedBody(t *testing.T) {
body := `{"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`
r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
}
func TestValidateRejectsNonJSONContentType(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(`{}`))
r.Header.Set("Content-Type", "text/plain")
w := httptest.NewRecorder()
testHandler(t).ServeHTTP(w, r)
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
}
func TestClientIPUsesNearestUntrustedForwardedAddress(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "https://example.org/", nil)
r.RemoteAddr = "10.0.0.2:443"
r.Header.Set("X-Forwarded-For", "198.51.100.200, 203.0.113.50, 10.0.0.3")
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
if got := clientIP(r, true, trusted); got != "203.0.113.50" {
t.Fatalf("unexpected client IP %q", got)
}
}
func TestTrustProxyRequiresExplicitCIDRs(t *testing.T) {
_, err := New(context.Background(), Config{
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", TrustProxy: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXY_CIDRS") {
t.Fatalf("expected trusted proxy validation error, got %v", err)
}
}
+8 -8
View File
@@ -97,56 +97,56 @@ func officialSources(lang string) []Source {
titles := map[string][5]Source{
"de": {
{Title: "Verordnung (EU) 2024/1689 – AI Act", Description: "Verbindlicher Gesetzestext, insbesondere Artikel 50 zu Transparenzpflichten für bestimmte KI-Systeme.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Leitlinien zu Transparenzpflichten", Description: "Von der Europäischen Kommission am 20. Juli 2026 veröffentlichte Leitlinien zum Anwendungsbereich von Artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Leitlinien zu Transparenzpflichten", Description: "Von der Europäischen Kommission am 20. Juli 2026 veröffentlichte Leitlinien zum Anwendungsbereich von Artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Fragen und Antworten zu Artikel 50", Description: "Praktische Erläuterungen zu Anwendungsbereich, Ausnahmen, menschlicher Prüfung und redaktioneller Kontrolle.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Code of Practice zu KI-generierten Inhalten", Description: "Freiwilliges Instrument zur Unterstützung der Umsetzung der Kennzeichnungs- und Markierungspflichten.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "EU-Icons für KI-generierte Inhalte", Description: "Optionale Symbole der Europäischen Union; ihre Verwendung allein begründet keine Rechtskonformität.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"en": {
{Title: "Regulation (EU) 2024/1689 – AI Act", Description: "Binding legal text, in particular Article 50 on transparency obligations for certain AI systems.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Guidelines on transparency obligations", Description: "European Commission guidelines published on 20 July 2026 clarifying the scope of Article 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Guidelines on transparency obligations", Description: "European Commission guidelines published on 20 July 2026 clarifying the scope of Article 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Questions and answers on Article 50", Description: "Practical explanations of scope, exceptions, human review and editorial control.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Code of Practice on AI-generated content", Description: "A voluntary instrument supporting implementation of marking and labelling obligations.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "EU icons for AI-generated content", Description: "Optional European Union icons; using them alone does not establish legal compliance.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"fr": {
{Title: "Règlement (UE) 2024/1689 – AI Act", Description: "Texte juridique contraignant, notamment l’article 50 relatif aux obligations de transparence de certains systèmes d’IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Lignes directrices sur les obligations de transparence", Description: "Lignes directrices de la Commission européenne publiées le 20 juillet 2026 sur le champ d’application de l’article 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Lignes directrices sur les obligations de transparence", Description: "Lignes directrices de la Commission européenne publiées le 20 juillet 2026 sur le champ d’application de l’article 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Questions et réponses sur l’article 50", Description: "Explications pratiques sur le champ d’application, les exceptions, l’examen humain et le contrôle éditorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Code de bonnes pratiques sur les contenus générés par l’IA", Description: "Instrument volontaire destiné à faciliter la mise en œuvre des obligations de marquage et d’étiquetage.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Icônes de l’UE pour les contenus générés par l’IA", Description: "Symboles facultatifs de l’Union européenne ; leur utilisation seule ne prouve pas la conformité juridique.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"es": {
{Title: "Reglamento (UE) 2024/1689 – Ley de IA", Description: "Texto jurídico vinculante, en especial el artículo 50 sobre obligaciones de transparencia para determinados sistemas de IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Directrices sobre obligaciones de transparencia", Description: "Directrices de la Comisión Europea publicadas el 20 de julio de 2026 sobre el ámbito del artículo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Directrices sobre obligaciones de transparencia", Description: "Directrices de la Comisión Europea publicadas el 20 de julio de 2026 sobre el ámbito del artículo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Preguntas y respuestas sobre el artículo 50", Description: "Explicaciones prácticas sobre ámbito, excepciones, revisión humana y control editorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Código de buenas prácticas sobre contenido generado por IA", Description: "Instrumento voluntario para apoyar la aplicación de las obligaciones de marcado y etiquetado.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Iconos de la UE para contenido generado por IA", Description: "Símbolos opcionales de la Unión Europea; su uso por sí solo no acredita el cumplimiento jurídico.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"it": {
{Title: "Regolamento (UE) 2024/1689 – AI Act", Description: "Testo giuridico vincolante, in particolare l’articolo 50 sugli obblighi di trasparenza per determinati sistemi di IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Orientamenti sugli obblighi di trasparenza", Description: "Orientamenti della Commissione europea pubblicati il 20 luglio 2026 sull’ambito di applicazione dell’articolo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Orientamenti sugli obblighi di trasparenza", Description: "Orientamenti della Commissione europea pubblicati il 20 luglio 2026 sull’ambito di applicazione dell’articolo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Domande e risposte sull’articolo 50", Description: "Chiarimenti pratici su ambito, eccezioni, revisione umana e controllo editoriale.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Codice di buone pratiche sui contenuti generati dall’IA", Description: "Strumento volontario a sostegno dell’attuazione degli obblighi di marcatura ed etichettatura.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Icone UE per i contenuti generati dall’IA", Description: "Simboli facoltativi dell’Unione europea; il loro uso da solo non dimostra la conformità giuridica.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"nl": {
{Title: "Verordening (EU) 2024/1689 – AI-verordening", Description: "Bindende wettekst, met name artikel 50 over transparantieverplichtingen voor bepaalde AI-systemen.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Richtsnoeren over transparantieverplichtingen", Description: "Richtsnoeren van de Europese Commissie van 20 juli 2026 over het toepassingsgebied van artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Richtsnoeren over transparantieverplichtingen", Description: "Richtsnoeren van de Europese Commissie van 20 juli 2026 over het toepassingsgebied van artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Vragen en antwoorden over artikel 50", Description: "Praktische uitleg over toepassingsgebied, uitzonderingen, menselijke beoordeling en redactionele controle.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Gedragscode voor door AI gegenereerde inhoud", Description: "Vrijwillig instrument ter ondersteuning van markerings- en etiketteringsverplichtingen.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "EU-pictogrammen voor door AI gegenereerde inhoud", Description: "Optionele symbolen van de Europese Unie; gebruik alleen bewijst geen juridische naleving.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"pt": {
{Title: "Regulamento (UE) 2024/1689 – Regulamento da IA", Description: "Texto jurídico vinculativo, em especial o artigo 50 sobre obrigações de transparência para determinados sistemas de IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Orientações sobre obrigações de transparência", Description: "Orientações da Comissão Europeia publicadas em 20 de julho de 2026 sobre o âmbito do artigo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Orientações sobre obrigações de transparência", Description: "Orientações da Comissão Europeia publicadas em 20 de julho de 2026 sobre o âmbito do artigo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Perguntas e respostas sobre o artigo 50", Description: "Explicações práticas sobre âmbito, exceções, revisão humana e controlo editorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Código de boas práticas sobre conteúdos gerados por IA", Description: "Instrumento voluntário de apoio à execução das obrigações de marcação e rotulagem.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Ícones da UE para conteúdos gerados por IA", Description: "Símbolos opcionais da União Europeia; a sua utilização, por si só, não comprova conformidade jurídica.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
},
"pl": {
{Title: "Rozporządzenie (UE) 2024/1689 – akt w sprawie AI", Description: "Wiążący tekst prawny, w szczególności art. 50 dotyczący obowiązków przejrzystości dla niektórych systemów AI.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
{Title: "Wytyczne dotyczące obowiązków przejrzystości", Description: "Wytyczne Komisji Europejskiej opublikowane 20 lipca 2026 r., wyjaśniające zakres art. 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
{Title: "Wytyczne dotyczące obowiązków przejrzystości", Description: "Wytyczne Komisji Europejskiej opublikowane 20 lipca 2026 r., wyjaśniające zakres art. 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
{Title: "Pytania i odpowiedzi dotyczące art. 50", Description: "Praktyczne wyjaśnienia zakresu, wyjątków, przeglądu przez człowieka i kontroli redakcyjnej.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
{Title: "Kodeks postępowania dotyczący treści generowanych przez AI", Description: "Dobrowolne narzędzie wspierające realizację obowiązków znakowania i oznaczania.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
{Title: "Ikony UE dla treści generowanych przez AI", Description: "Opcjonalne symbole Unii Europejskiej; samo ich użycie nie potwierdza zgodności prawnej.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
+1 -1
View File
@@ -418,7 +418,7 @@ func french() copySet {
c := english()
c.MetaDescription = "Produit, tarifs et installation de la norme ouverte de déclaration d'utilisation de l'IA."
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Fonctions", "Tarifs", "Installation", "Générateur", "Contexte", "Langue"
c.HeroEyebrow, c.HeroTitle = "Cœur open source · Pro si nécessaire", "Déclarez l'usage de l'IA sans dépendre d'une plateforme."
c.HeroEyebrow, c.HeroTitle = "Cœur open source · Pro si nécessaire", "Déclarez l’usage de l’IA sans dépendre d’une plateforme."
c.HeroLead = "Un service Go sans état pour badges SVG, pages explicatives et JSON-LD. Auto-hébergeable, internationalisé et conçu pour la haute disponibilité."
c.PrimaryCTA, c.SecondaryCTA = "Créer un badge", "Contacter Pro"
c.Proof = []string{"8 langues", "Sans cookies", "Sans base de données", "Docker & Kubernetes"}
+14 -1
View File
@@ -113,7 +113,20 @@ paths:
schema:
type: object
properties:
license: {type: object}
license:
type: object
required: [edition, licensed, features]
additionalProperties: false
properties:
edition: {type: string}
licensed: {type: boolean}
features:
type: array
items: {type: string}
limits:
type: object
additionalProperties: {type: integer, format: int64}
expiresAt: {type: string, format: date-time}
supportedLanguages:
type: array
items: {type: string}
+7
View File
@@ -7,6 +7,13 @@
});
});
document.querySelectorAll('[data-url-switch]').forEach(select => {
select.addEventListener('change', event => {
const target = event.target.value;
if (target) window.location.assign(target);
});
});
document.querySelectorAll('.copy-code').forEach(button => {
button.addEventListener('click', async () => {
const target = document.getElementById(button.dataset.copyTarget);
+4
View File
@@ -42,3 +42,7 @@
.background-page{background:var(--bg)}.background-header nav a[aria-current="page"]{font-weight:900;text-decoration:underline;text-underline-offset:6px}.background-main{max-width:1180px;width:min(1180px,calc(100% - 40px));margin:0 auto;padding:0}.background-hero{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(320px,.75fr);gap:clamp(30px,7vw,92px);align-items:end;padding:clamp(58px,9vw,126px) 0 72px}.background-hero-copy h1{max-width:900px;font-size:clamp(3.2rem,7vw,7.2rem);line-height:.91;letter-spacing:-.07em;margin:.18em 0}.background-hero-copy .lead{max-width:820px}.legal-status{padding:28px;border:1px solid var(--line);border-top:5px solid #7c3aed;border-radius:18px;background:var(--surface);box-shadow:var(--shadow)}.legal-status h2{font-size:clamp(1.55rem,3vw,2.35rem);letter-spacing:-.04em;margin:.35rem 0 .75rem}.legal-status p:last-child{color:var(--muted);line-height:1.7}.background-toc{display:grid;grid-template-columns:220px 1fr;gap:34px;padding:28px 0 50px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.background-toc .eyebrow{margin:.3rem 0}.background-toc ol{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 28px;margin:0;padding-left:1.4rem}.background-toc li{padding:5px 0;color:var(--muted)}.background-toc a{color:var(--ink);font-weight:750;text-underline-offset:4px}.background-article{margin-top:6px}.background-section{display:grid;grid-template-columns:92px minmax(0,1fr);gap:30px;padding:88px 0;border-bottom:1px solid var(--line);scroll-margin-top:30px}.section-number{margin:8px 0 0;font:800 .78rem ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.14em;color:var(--muted)}.background-section h2{font-size:clamp(2rem,4.6vw,4.4rem);line-height:1;letter-spacing:-.055em;margin:0 0 26px}.prose-section>div{max-width:900px}.prose-section>div>p{font-size:clamp(1.06rem,1.45vw,1.28rem);line-height:1.78;color:#383838}.section-heading.compact{max-width:900px;margin-bottom:38px}.section-heading.compact p{font-size:1.08rem;line-height:1.7;color:var(--muted)}.legal-card-grid,.principle-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.legal-card,.principle-card{padding:27px;border:1px solid var(--line);border-radius:16px;background:var(--surface)}.legal-card h3,.principle-card h3{font-size:1.45rem;letter-spacing:-.035em;margin:.35rem 0 .8rem}.legal-card p:last-child,.principle-card p:last-child{margin-bottom:0;color:var(--muted);line-height:1.68}.scenario-list{border:1px solid var(--line);border-radius:17px;overflow:hidden;background:var(--surface)}.scenario-row{display:grid;grid-template-columns:minmax(180px,.32fr) minmax(0,1fr);gap:28px;padding:28px;border-bottom:1px solid var(--line)}.scenario-row:last-child{border-bottom:0}.scenario-row h3{font-size:1.35rem;letter-spacing:-.025em;margin:0 0 8px}.scenario-row p{margin:0;color:var(--muted);line-height:1.65}.scenario-status{display:inline-block;padding:7px 11px;border:1px solid #d8d1f5;border-radius:999px;background:#f4f1ff;color:#4f2aa8;font-size:.74rem;font-weight:850;letter-spacing:.035em}.project-scope-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 30px;margin:34px 0 0;padding:0;list-style:none;border-top:1px solid var(--line)}.project-scope-list li{position:relative;padding:16px 12px 16px 28px;border-bottom:1px solid var(--line);font-weight:700}.project-scope-list li::before{content:"✓";position:absolute;left:2px;color:#5b35b5}.source-list{display:grid;gap:10px}.source-card{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px 24px;border:1px solid var(--line);border-radius:14px;background:var(--surface);color:var(--ink);text-decoration:none;transition:transform .15s ease,border-color .15s ease}.source-card:hover{transform:translateY(-2px);border-color:#777}.source-card span:first-child{display:grid;gap:5px}.source-card strong{font-size:1.04rem}.source-card small{color:var(--muted);line-height:1.5}.source-card span:last-child{font-size:1.35rem}.background-disclaimer{margin:58px 92px;padding:30px 34px;border:1px solid #d8d1f5;border-left:5px solid #7c3aed;border-radius:15px;background:#f7f4ff}.background-disclaimer h2{font-size:1.4rem;margin:0 0 8px}.background-disclaimer p{margin:0;line-height:1.7;color:#49415d}.background-cta{display:flex;align-items:center;justify-content:space-between;gap:34px;margin:70px 0 100px;padding:clamp(34px,6vw,68px);border-radius:24px;background:#171717;color:#fff}.background-cta h2{font-size:clamp(2rem,4vw,4rem);letter-spacing:-.055em;margin:0 0 12px}.background-cta p{max-width:720px;margin:0;color:#c8c8c8;font-size:1.08rem}.background-cta .button{flex:0 0 auto;background:#fff;color:#111;border-color:#fff}
@media(max-width:900px){.background-hero{grid-template-columns:1fr;align-items:start}.legal-status{max-width:720px}.background-toc{grid-template-columns:1fr}.background-toc ol{grid-template-columns:repeat(2,minmax(0,1fr))}.background-section{grid-template-columns:56px minmax(0,1fr);gap:16px}.background-disclaimer{margin-left:56px;margin-right:0}.background-cta{align-items:flex-start;flex-direction:column}}
@media(max-width:650px){.background-main{width:min(100% - 28px,1180px);padding:0}.background-hero{padding-top:46px}.background-hero-copy h1{font-size:clamp(3rem,16vw,5rem)}.background-toc ol{grid-template-columns:1fr}.background-section{grid-template-columns:1fr;padding:62px 0}.section-number{margin:0}.legal-card-grid,.principle-grid,.project-scope-list{grid-template-columns:1fr}.scenario-row{grid-template-columns:1fr;gap:14px;padding:22px}.background-disclaimer{margin:42px 0;padding:24px}.background-cta{margin-bottom:72px}}
/* Legal pages and footer navigation */
.footer-legal{display:flex;flex-wrap:wrap;gap:12px 20px;align-items:center}.footer-legal a{color:inherit;text-underline-offset:4px}.legal-page{background:var(--bg)}.legal-main{width:min(960px,calc(100% - 40px));margin:0 auto;padding:0 0 96px}.legal-hero{padding:clamp(54px,9vw,108px) 0 42px;border-bottom:1px solid var(--line)}.legal-hero h1{font-size:clamp(3rem,8vw,6.5rem);line-height:.93;letter-spacing:-.065em;margin:.15em 0}.legal-hero .lead{max-width:800px}.legal-updated{color:var(--muted);font-size:.92rem;margin-top:26px}.legal-language{display:flex;gap:8px}.legal-language a{display:inline-flex;align-items:center;justify-content:center;min-width:38px;min-height:38px;border:1px solid var(--line);border-radius:999px;text-decoration:none;font-weight:800}.configuration-warning{margin:34px 0;padding:22px 24px;border:2px solid #9b2c2c;border-radius:14px;background:#fff5f5;color:#651b1b}.configuration-warning strong{display:block;font-size:1.05rem}.configuration-warning p{margin:.5rem 0 0;line-height:1.6}.legal-content{display:grid;gap:0}.legal-section{padding:52px 0;border-bottom:1px solid var(--line)}.legal-section h2{font-size:clamp(1.65rem,4vw,2.8rem);letter-spacing:-.045em;margin:0 0 22px}.legal-section p,.legal-section li{max-width:82ch;line-height:1.75}.legal-section ul{padding-left:1.4rem}.legal-fields{margin:26px 0 0;border:1px solid var(--line);border-radius:14px;overflow:hidden;background:#fff}.legal-fields div{display:grid;grid-template-columns:minmax(190px,.7fr) minmax(0,1.4fr);border-top:1px solid var(--line)}.legal-fields div:first-child{border-top:0}.legal-fields dt,.legal-fields dd{padding:15px 17px;margin:0;white-space:pre-line;overflow-wrap:anywhere}.legal-fields dt{font-weight:800;background:#f3f1ec;border-right:1px solid var(--line)}.legal-fields a{overflow-wrap:anywhere}
@media(max-width:700px){.legal-main{width:min(100% - 28px,960px)}.legal-fields div{grid-template-columns:1fr}.legal-fields dt{border-right:0;border-bottom:1px solid var(--line)}.site-header .legal-language{margin-left:auto}footer{align-items:flex-start;gap:18px}.footer-legal{justify-content:flex-start}}
+1 -1
View File
@@ -158,7 +158,7 @@
</section>
</main>
<footer><span>{{.Background.Footer}}</span><span>App 1.6.2 · Schema 1.1 · {{.License.Edition}}</span></footer>
<footer><span>{{.Background.Footer}} · App 1.6.2 · Schema 1.1 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>
+4 -3
View File
@@ -11,7 +11,7 @@
{{range .LanguageLinks}}<link rel="alternate" hreflang="{{.Code}}" href="{{.AbsoluteURL}}">{{end}}
<link rel="alternate" hreflang="x-default" href="{{.DefaultLanguageURL}}">
<link rel="stylesheet" href="/static/style.css">
<script type="application/ld+json">{{.JSONLD}}</script>
<script nonce="{{.CSPNonce}}" type="application/ld+json">{{.JSONLD}}</script>
</head>
<body>
<header class="site-header"><a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a><nav><a href="/?lang={{.Lang}}">{{index .Text "nav_generator"}}</a><a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a><a href="{{.ManifestURL}}">JSON-LD</a></nav></header>
@@ -22,7 +22,7 @@
<p class="eyebrow">{{index .Text "declaration_eyebrow"}} · Schema {{.Declaration.SchemaVersion}}</p>
<form class="declaration-language" method="get" action="/declaration">
<label for="declaration-language">{{index .Text "field_language"}}</label>
<select id="declaration-language" aria-label="{{index .Text "field_language"}}" onchange="window.location.assign(this.value)">
<select id="declaration-language" aria-label="{{index .Text "field_language"}}" data-url-switch>
{{range .LanguageLinks}}<option value="{{.URL}}" lang="{{.Code}}"{{if .Current}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
<noscript><div class="language-link-list">{{range .LanguageLinks}}<a href="{{.URL}}" lang="{{.Code}}"{{if .Current}} aria-current="page"{{end}}>{{.Name}}</a>{{end}}</div></noscript>
@@ -54,7 +54,8 @@
<div class="notice"><strong>{{index .Text "transparency_label"}}</strong> {{index .Text "transparency_text"}}</div>
</article>
</main>
<footer><span><a href="{{.ManifestURL}}">{{index .Text "manifest"}}</a></span><span><a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a></span></footer>
<footer><span><a href="{{.ManifestURL}}">{{index .Text "manifest"}}</a> · <a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a></span>{{template "legal-links" .}}</footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>
{{end}}
+2 -2
View File
@@ -132,8 +132,8 @@
<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.6.2 · Schema 1.1 · {{.License.Edition}}</span><span>{{index .Text "footer_no_legal"}}</span></footer>
<script>window.APP_CONFIG={{.AppConfig}};</script>
<footer><span>AI Usage Disclosure · App 1.6.2 · Schema 1.1 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<script nonce="{{.CSPNonce}}">window.APP_CONFIG={{.AppConfig}};</script>
<script src="/static/app.js" defer></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
{{define "legal-links"}}
<nav class="footer-legal" aria-label="Legal">
<a href="/impressum?lang={{.Lang}}">{{.LegalNav.Imprint}}</a>
<a href="/datenschutz?lang={{.Lang}}">{{.LegalNav.Privacy}}</a>
<a href="/barrierefreiheit?lang={{.Lang}}">{{.LegalNav.Accessibility}}</a>
</nav>
{{end}}
{{define "legal.html"}}
<!doctype html>
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Legal.Title}} · {{.Name}}</title>
<meta name="description" content="{{.Legal.MetaDescription}}">
<link rel="canonical" href="{{.CanonicalURL}}">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="legal-page">
<header class="site-header">
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a>
<nav>
<a href="/?lang={{.Lang}}">{{if eq .Lang "de"}}Generator{{else}}Generator{{end}}</a>
<a href="/background?lang={{.Lang}}">{{if eq .Lang "de"}}Hintergrund{{else}}Background{{end}}</a>
</nav>
<div class="legal-language" aria-label="Language">
<a href="{{.CanonicalURL}}?lang=de" lang="de">DE</a>
<a href="{{.CanonicalURL}}?lang=en" lang="en">EN</a>
</div>
</header>
<main class="legal-main">
<header class="legal-hero">
<p class="eyebrow">{{.Legal.Eyebrow}}</p>
<h1>{{.Legal.Title}}</h1>
<p class="lead">{{.Legal.Intro}}</p>
<p class="legal-updated">{{if eq .Lang "de"}}Stand{{else}}Last updated{{end}}: {{.Legal.LastUpdated}}</p>
</header>
{{if .Legal.Warning}}
<aside class="configuration-warning" role="alert">
<strong>{{if eq .Lang "de"}}Konfiguration erforderlich{{else}}Configuration required{{end}}</strong>
<p>{{.Legal.Warning}}</p>
</aside>
{{end}}
<article class="legal-content">
{{range .Legal.Sections}}
<section class="legal-section">
<h2>{{.Title}}</h2>
{{range .Paragraphs}}<p>{{.}}</p>{{end}}
{{if .Fields}}
<dl class="legal-fields">
{{range .Fields}}
<div><dt>{{.Label}}</dt><dd>{{if .URL}}<a href="{{.URL}}">{{.Value}}</a>{{else}}{{.Value}}{{end}}</dd></div>
{{end}}
</dl>
{{end}}
{{if .Bullets}}<ul>{{range .Bullets}}<li>{{.}}</li>{{end}}</ul>{{end}}
</section>
{{end}}
</article>
</main>
<footer><span>{{.Name}}</span>{{template "legal-links" .}}</footer>
</body>
</html>
{{end}}
+1 -1
View File
@@ -119,7 +119,7 @@
</section>
</main>
<footer><span>{{.Marketing.Footer}}</span><span>App 1.6.2 · Schema 1.1 · {{.License.Edition}}</span></footer>
<footer><span>{{.Marketing.Footer}} · App 1.6.2 · Schema 1.1 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
<script src="/static/marketing.js" defer></script>
</body>
</html>