diff --git a/.env.example b/.env.example index 371394e..c9ac807 100644 --- a/.env.example +++ b/.env.example @@ -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 - diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f28bc..44b7356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index 4707f78..4be2c2b 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/SECURITY.md b/SECURITY.md index f147454..5426771 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/compose.yaml b/compose.yaml index cde88bb..ca2a620 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml index c1fdd0e..3871207 100644 --- a/deploy/kubernetes.yaml +++ b/deploy/kubernetes.yaml @@ -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: diff --git a/deploy/swarm-stack.yaml b/deploy/swarm-stack.yaml index a796010..1ead930 100644 --- a/deploy/swarm-stack.yaml +++ b/deploy/swarm-stack.yaml @@ -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:-}" diff --git a/docs/LEGAL-AND-SECURITY.md b/docs/LEGAL-AND-SECURITY.md new file mode 100644 index 0000000..a12a5aa --- /dev/null +++ b/docs/LEGAL-AND-SECURITY.md @@ -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 diff --git a/docs/REVIEW-2026-07-20.md b/docs/REVIEW-2026-07-20.md new file mode 100644 index 0000000..88d33ed --- /dev/null +++ b/docs/REVIEW-2026-07-20.md @@ -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 diff --git a/internal/app/config.go b/internal/app/config.go index cfdd899..5ab6d5c 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -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 == "" { diff --git a/internal/app/legal.go b/internal/app/legal.go new file mode 100644 index 0000000..b8a26a1 --- /dev/null +++ b/internal/app/legal.go @@ -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) +} diff --git a/internal/app/server.go b/internal/app/server.go index 5d7d278..565960d 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -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 diff --git a/internal/app/server_test.go b/internal/app/server_test.go index b2efc5c..a084200 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -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 ", + }, 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) + } +} diff --git a/internal/background/content.go b/internal/background/content.go index bb79c74..d93a891 100644 --- a/internal/background/content.go +++ b/internal/background/content.go @@ -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"}, diff --git a/internal/marketing/content.go b/internal/marketing/content.go index 1ffe6a9..db5e997 100644 --- a/internal/marketing/content.go +++ b/internal/marketing/content.go @@ -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"} diff --git a/openapi.yaml b/openapi.yaml index a6c044f..f98aac4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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} diff --git a/web/static/marketing.js b/web/static/marketing.js index 1b6d7d8..2663e63 100644 --- a/web/static/marketing.js +++ b/web/static/marketing.js @@ -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); diff --git a/web/static/style.css b/web/static/style.css index 2a48c59..6345096 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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}} diff --git a/web/templates/background.html b/web/templates/background.html index 611c9a2..e18237d 100644 --- a/web/templates/background.html +++ b/web/templates/background.html @@ -158,7 +158,7 @@ - + diff --git a/web/templates/declaration.html b/web/templates/declaration.html index 335119e..5cd1e2f 100644 --- a/web/templates/declaration.html +++ b/web/templates/declaration.html @@ -11,7 +11,7 @@ {{range .LanguageLinks}}{{end}} - + @@ -22,7 +22,7 @@

{{index .Text "declaration_eyebrow"}} · Schema {{.Declaration.SchemaVersion}}

- {{range .LanguageLinks}}{{end}} @@ -54,7 +54,8 @@
{{index .Text "transparency_label"}} {{index .Text "transparency_text"}}
- + + {{end}} diff --git a/web/templates/index.html b/web/templates/index.html index 824aaa4..3b70183 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -132,8 +132,8 @@

HA

{{index .Text "api_stateless_title"}}

{{index .Text "api_stateless_desc"}}

-
AI Usage Disclosure · App 1.6.2 · Schema 1.1 · {{.License.Edition}}{{index .Text "footer_no_legal"}}
- +
AI Usage Disclosure · App 1.6.2 · Schema 1.1 · {{.License.Edition}}{{template "legal-links" .}}
+ diff --git a/web/templates/legal.html b/web/templates/legal.html new file mode 100644 index 0000000..b895fd0 --- /dev/null +++ b/web/templates/legal.html @@ -0,0 +1,69 @@ +{{define "legal-links"}} + +{{end}} + +{{define "legal.html"}} + + + + + + {{.Legal.Title}} · {{.Name}} + + + + + + + +
+ + + {{if .Legal.Warning}} + + {{end}} + + +
+ +
{{.Name}}{{template "legal-links" .}}
+ + +{{end}} diff --git a/web/templates/marketing.html b/web/templates/marketing.html index 47d717d..977792c 100644 --- a/web/templates/marketing.html +++ b/web/templates/marketing.html @@ -119,7 +119,7 @@ -
{{.Marketing.Footer}}App 1.6.2 · Schema 1.1 · {{.License.Edition}}
+
{{.Marketing.Footer}} · App 1.6.2 · Schema 1.1 · {{.License.Edition}}{{template "legal-links" .}}