diff --git a/.env.example b/.env.example index c285afd..3d15be8 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,128 @@ +# ============================================================================= +# AI Usage Disclosure Standard - complete runtime configuration reference +# ============================================================================= +# Copy to .env and adjust for your deployment. Values ending in *_FILE are +# recommended for Docker/Kubernetes secrets. If a *_FILE value is configured, +# the file is authoritative and the corresponding plain environment value is +# ignored. + +# ----------------------------------------------------------------------------- +# HTTP / public identity +# ----------------------------------------------------------------------------- +LISTEN_ADDRESS=:8080 BASE_URL=http://localhost:8080 OUTPUT_BASE_URL=http://localhost:8080 PUBLIC_NAME=AI Usage Disclosure CONTACT_URL=https://b1tsblog.org/page/ai DEFAULT_LANGUAGE=de -TRUST_PROXY=false -# Runtime mode: full (UI + API), api (API without public product/generator pages), bulk. +# Used only by the binary's "--healthcheck" command inside containers. +HEALTHCHECK_URL=http://127.0.0.1:8080/healthz + +# Runtime mode: full (UI + API), api (machine APIs only), bulk (Bulk UI + API). SERVICE_MODE=full + +# CORS for /v1, /schema and /context endpoints. Empty disables CORS headers. API_ALLOWED_ORIGIN=* -# Runtime license issued by the standalone Universal License Platform. -# No private keys or configurable public keys belong in this application. -LICENSE_TOKEN= -LICENSE_MODE=offline -LICENSE_SERVER_URL= -LICENSE_INSTANCE_ID= -LICENSE_CACHE_FILE=/data/license-lease.json -LICENSE_REFRESH_INTERVAL=15m -LICENSE_REQUEST_TIMEOUT=5s +# ----------------------------------------------------------------------------- +# Reverse proxy, security and request logging +# ----------------------------------------------------------------------------- +# Proxy headers are trusted only when the direct peer matches one of these +# CIDRs. TRUST_PROXY=true without a valid CIDR list is rejected at startup. +TRUST_PROXY=false +TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + +# Client IP addresses are omitted from structured request logs by default. +LOG_CLIENT_IP=false + +# Enable only when the public service is served exclusively over HTTPS. +ENABLE_HSTS=false + +# A commercial deployment can fail closed when no valid runtime licence exists. REQUIRE_LICENSE=false WHITE_LABEL=false -# Bulk API. The dedicated bulk image forces BULK_REQUIRE_API_KEY=true. +# ----------------------------------------------------------------------------- +# Prometheus +# ----------------------------------------------------------------------------- +# /metrics returns 404 while disabled. When enabled, a bearer token of at least +# 32 characters is mandatory. Prefer METRICS_TOKEN_FILE in containers. +METRICS_ENABLED=false +METRICS_TOKEN= +METRICS_TOKEN_FILE= + +# ----------------------------------------------------------------------------- +# Operator / legal / privacy information +# ----------------------------------------------------------------------------- +# The application exposes /legal, /privacy and /accessibility in full and bulk +# mode. These pages are configurable operator information, not legal advice. +# +# LEGAL_STRICT=true prevents startup while the core operator/privacy fields are +# empty or contain REPLACE_ME, CHANGEME or TODO. For local development leave it +# false; set it to true after completing the production information. +LEGAL_STRICT=false + +LEGAL_NAME= +LEGAL_ADDRESS= +LEGAL_REPRESENTATIVE= +LEGAL_EMAIL= +LEGAL_PHONE= +LEGAL_REGISTER= +LEGAL_REGISTER_NUMBER= +LEGAL_VAT_ID= + +# Only set these after assessing whether a journalistically/editorially designed +# service requires a specifically named responsible person in your jurisdiction. +EDITORIAL_RESPONSIBLE_NAME= +EDITORIAL_RESPONSIBLE_ADDRESS= + +DATA_PROTECTION_CONTACT= +HOSTING_PROVIDER= +HOSTING_ADDRESS= +LOG_RETENTION=7 days +DATA_RECIPIENTS= +THIRD_COUNTRY_TRANSFERS= +SUPERVISORY_AUTHORITY_NAME= +SUPERVISORY_AUTHORITY_URL= + +# One of: not_applicable, not_participating, participating. +# If "participating" is selected and LEGAL_STRICT=true, body and URL are required. +CONSUMER_DISPUTE_STATUS=not_applicable +CONSUMER_DISPUTE_BODY= +CONSUMER_DISPUTE_URL= + +ACCESSIBILITY_CONTACT= +ACCESSIBILITY_STATUS= + +# ----------------------------------------------------------------------------- +# Runtime licence - issued by the standalone Universal License Platform +# ----------------------------------------------------------------------------- +# No private signing key and no configurable issuer public key belong here. +# Prefer LICENSE_TOKEN_FILE for Docker/Kubernetes secrets. +LICENSE_TOKEN= +LICENSE_TOKEN_FILE= +LICENSE_INSTANCE_ID= +LICENSE_MODE=offline +LICENSE_SERVER_URL= +LICENSE_CACHE_FILE=/data/license-lease.json +LICENSE_REFRESH_INTERVAL=15m +LICENSE_REQUEST_TIMEOUT=5s + +# ----------------------------------------------------------------------------- +# Bulk workspace / API +# ----------------------------------------------------------------------------- +# The dedicated bulk image forces the intended bulk service mode and can require +# the licensed "bulk_api" capability. BULK_MAX_ITEMS may be further reduced by +# the licence limit "bulk_items". BULK_MAX_ITEMS=500 BULK_MAX_BODY_BYTES=4194304 BULK_REQUIRE_API_KEY=false BULK_API_KEY= +BULK_API_KEY_FILE= + +# ----------------------------------------------------------------------------- +# Removed / intentionally unsupported legacy marketing variables +# ----------------------------------------------------------------------------- +# SALES_URL and PRICE_* are intentionally not runtime settings anymore. Pricing +# and Pro advertising were removed from the public application UI. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f89172..dba58d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 2.0.2 + +- `.env.example` ist nun die vollständige Runtime-Konfigurationsreferenz statt nur eines Minimalbeispiels. +- Vertrauenswürdige Reverse-Proxy-Netze über `TRUSTED_PROXY_CIDRS`; weitergereichte Client-IP-Header werden nur von explizit vertrauenswürdigen Peers akzeptiert. +- Datenschutzfreundliches Request-Logging mit `LOG_CLIENT_IP=false` als Standard und optionales `ENABLE_HSTS`. +- Prometheus ist standardmäßig deaktiviert und kann nur mit Bearer-Token (`METRICS_TOKEN` / `METRICS_TOKEN_FILE`) aktiviert werden. +- Mehrsprachige Betreiberseiten `/legal`, `/imprint`, `/privacy` und `/accessibility` mit konfigurierbaren Betreiber-, Datenschutz- und Barrierefreiheitsangaben. +- `LEGAL_STRICT` als Fail-Fast-Prüfung für zentrale Betreiber-/Datenschutzfelder und die Konfiguration der Verbraucherstreitbeilegung. +- Betreiberseiten und ihre statischen Assets bleiben auch bei einer Runtime-Lizenzstörung erreichbar. +- Konfigurations-Regressionstests sowie neue Dokumentation in `docs/CONFIGURATION.md` und `docs/OPERATOR-PAGES.md`. +- `SALES_URL` und `PRICE_*` bleiben bewusst entfernt, da Preisübersicht und Pro-Werbung nicht mehr Teil der öffentlichen Anwendung sind. + +## 2.0.1 + +- **Bulk Workspace wiederhergestellt:** Der dedizierte Bulk-Container liefert wieder eine vollständige Weboberfläche unter `/` und `/bulk`. +- Visueller Workflow zum Anwenden eines gemeinsamen Artikelprofils auf viele URLs/IDs. +- Erweiterter JSON-Modus für heterogene Bulk-Requests bleibt direkt mit `POST /v1/bulk/declarations` kompatibel. +- Ergebnisansicht mit Erfolgs-/Fehlerstatus, Artikel-50-Einordnung sowie direkten Links zu Erklärung, Manifest und Badge. +- JSON- und CSV-Export der Bulk-Ergebnisse. +- API-Key-Eingabe wird ausschließlich im `sessionStorage` des aktuellen Browser-Tabs gehalten; der Schlüssel wird nicht in Ergebnisse geschrieben. +- Bulk-Workspace respektiert `bulk_api`, `bulk_items`, `BULK_MAX_ITEMS` und `BULK_REQUIRE_API_KEY`. +- Dedizierter Bulk-Container behält Fail-Closed-Lizenzierung, API-Key-Schutz, Read-only-Filesystem und alle 2.0-Härtungen bei. +- `LICENSE_TOKEN_FILE` und `BULK_API_KEY_FILE` haben nun Vorrang vor gleichnamigen Environment-Werten; veraltete `.env`-Secrets können Docker-/Kubernetes-Secrets nicht mehr überschreiben. +- Bulk-Oberfläche für alle acht unterstützten Sprachen vorbereitet; Deutsch und Englisch vollständig redaktionell ausgearbeitet, weitere Sprachen mit lokalisierten Kerntexten und englischem Fallback für seltene Verwaltungsbegriffe. + ## 2.0.0 ### Article 50 decision support diff --git a/Dockerfile b/Dockerfile index f9ed844..44ca2fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN CGO_ENABLED=0 GOOS=linux go test ./... && \ CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server FROM scratch -ARG VERSION=2.0.0 +ARG VERSION=2.0.2 ARG REVISION=unknown ARG CREATED=unknown LABEL org.opencontainers.image.title="AI Disclosure Standard" \ diff --git a/Dockerfile.bulk b/Dockerfile.bulk index ac89354..2a061bb 100644 --- a/Dockerfile.bulk +++ b/Dockerfile.bulk @@ -12,11 +12,11 @@ RUN CGO_ENABLED=0 GOOS=linux go test ./... && \ CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server FROM scratch -ARG VERSION=2.0.0 +ARG VERSION=2.0.2 ARG REVISION=unknown ARG CREATED=unknown LABEL org.opencontainers.image.title="AI Disclosure Standard Bulk" \ - org.opencontainers.image.description="Licensed server-to-server bulk processing service for AI usage declarations" \ + org.opencontainers.image.description="Licensed bulk workspace and API for professional AI usage declaration processing" \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.revision="${REVISION}" \ org.opencontainers.image.created="${CREATED}" \ diff --git a/Makefile b/Makefile index 157364a..f69ce7a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: run test test-license-client check build docker-build docker-build-bulk docker-build-all -VERSION ?= 2.0.0 +VERSION ?= 2.0.2 run: go run ./cmd/server diff --git a/README.md b/README.md index cdcf6ef..c49d268 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AI Disclosure Standard 2.0.0 +# AI Disclosure Standard 2.0.2 Ein mehrsprachiger, selbst hostbarer Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen. @@ -99,7 +99,7 @@ docker compose --env-file .env up -d --build |---|---| | `full` | Generator, Produkt-/Hintergrundseiten, Deklarationen und APIs | | `api` | Deklarations-, Badge- und Maschinen-APIs ohne öffentliche Generator-/Produktseite | -| `bulk` | ausschließlich Betriebs-/Validierungs-/Bulk-Endpunkte | +| `bulk` | eigenständiger Bulk Workspace plus Bulk-/Validierungs-/Betriebs-APIs | Der dedizierte Bulk-Container setzt bereits: @@ -112,13 +112,22 @@ API_ALLOWED_ORIGIN= ## Professioneller Bulk-Betrieb +Der Bulk-Container enthält wieder die vollständige browserbasierte **Bulk Workspace**-Oberfläche. Sie ist unter `/` und `/bulk` erreichbar und ergänzt die API, statt sie zu ersetzen. API-Keys werden im Browser nur im `sessionStorage` des aktuellen Tabs gehalten. + Build: ```bash -docker build -f Dockerfile.bulk -t ai-disclosure-standard-bulk:2.0.0 . +docker build -f Dockerfile.bulk -t ai-disclosure-standard-bulk:2.0.2 . ``` -Der Bulk-Endpunkt lautet: +Die Oberfläche ist nach dem Start erreichbar unter: + +```text +http://localhost:8080/ +http://localhost:8080/bulk +``` + +Der programmatische Bulk-Endpunkt lautet: ```text POST /v1/bulk/declarations @@ -172,7 +181,9 @@ BULK_REQUIRE_API_KEY=true BULK_API_KEY_FILE=/run/secrets/bulk/key ``` -Siehe [`docs/BULK-API.md`](docs/BULK-API.md). +Ist `LICENSE_TOKEN_FILE` bzw. `BULK_API_KEY_FILE` gesetzt, ist die angegebene Datei **autoritativ** und hat Vorrang vor einem eventuell gleichzeitig vorhandenen `LICENSE_TOKEN` bzw. `BULK_API_KEY` aus `.env`. Dadurch können veraltete Environment-Werte Docker-/Kubernetes-Secrets nicht unbemerkt überschreiben. + +Siehe [`docs/BULK-API.md`](docs/BULK-API.md) und [`docs/BULK-WORKSPACE.md`](docs/BULK-WORKSPACE.md). ## Supply-Chain-Sicherheit @@ -266,7 +277,7 @@ LICENSE_SERVER_URL=https://licenses.example.org \ docker compose -f compose.commercial.yaml up -d --build ``` -Der Bulk-Dienst nutzt `OUTPUT_BASE_URL`, damit seine Resultate auf die öffentliche Full-/API-Instanz verweisen können, obwohl der Bulk-Container selbst keine Deklarationsseiten ausliefert. Der Bulk-Port ist in diesem Beispiel nur an `127.0.0.1:8081` gebunden. In einer echten Produktionsumgebung sollte der Bulk-Service intern bleiben oder hinter einem authentifizierenden API-Gateway betrieben werden. +Der Bulk-Dienst nutzt `OUTPUT_BASE_URL`, damit seine Resultate auf die öffentliche Full-/API-Instanz verweisen können. Die Bulk-Instanz liefert selbst den Bulk Workspace, aber bewusst keine öffentlichen Deklarationsseiten aus. Der Bulk-Port ist in diesem Beispiel nur an `127.0.0.1:8081` gebunden. In einer echten Produktionsumgebung sollte der Bulk-Service intern bleiben oder hinter einem authentifizierenden API-Gateway betrieben werden. ## Kubernetes @@ -299,32 +310,21 @@ Siehe [`docs/COMMERCIAL-DEPLOYMENT.md`](docs/COMMERCIAL-DEPLOYMENT.md). ## Konfiguration -| Variable | Standard | Bedeutung | -|---|---|---| -| `LISTEN_ADDRESS` | `:8080` | HTTP-Adresse | -| `BASE_URL` | `http://localhost:8080` | kanonische URL dieser Instanz und Grundlage der Lizenz-Domainprüfung | -| `OUTPUT_BASE_URL` | `BASE_URL` | Basis-URL für generierte Deklarations-/Manifest-/Badge-Links; besonders für getrennte Bulk-Dienste | -| `PUBLIC_NAME` | `AI Usage Disclosure` | sichtbarer Produktname | -| `CONTACT_URL` | Projektseite | externe Kontakt-/Projektseite | -| `DEFAULT_LANGUAGE` | `de` | Standardsprache | -| `TRUST_PROXY` | `false` | Proxy-Header für Client-IP berücksichtigen | -| `SERVICE_MODE` | `full` | `full`, `api` oder `bulk` | -| `API_ALLOWED_ORIGIN` | `*` | CORS-Origin für API-Endpunkte; leer deaktiviert CORS | -| `REQUIRE_LICENSE` | `false` | ohne gültige Runtime-Lizenz fachliche Endpunkte mit 503 sperren | -| `WHITE_LABEL` | `false` | Standardattribution ausblenden, wenn Capability `white_label` vorhanden | -| `LICENSE_TOKEN` | leer | Runtime-Lizenz | -| `LICENSE_TOKEN_FILE` | leer | Datei-Alternative für Runtime-Lizenz | -| `LICENSE_MODE` | `offline` | `offline`, `hybrid` oder `online` | -| `LICENSE_SERVER_URL` | leer | optionaler Prüfserver-Override | -| `LICENSE_INSTANCE_ID` | leer | optionale Instanzbindung | -| `LICENSE_CACHE_FILE` | `./data/license-lease.json` | signierter Hybrid-Lease-Cache | -| `LICENSE_REFRESH_INTERVAL` | `15m` | Hintergrundaktualisierung | -| `LICENSE_REQUEST_TIMEOUT` | `5s` | Timeout der Onlineprüfung | -| `BULK_MAX_ITEMS` | `500` | serverseitige Bulk-Obergrenze | -| `BULK_MAX_BODY_BYTES` | `4194304` | maximale Bulk-Requestgröße | -| `BULK_REQUIRE_API_KEY` | `false` | Bulk-Endpunkt zusätzlich per API-Key schützen | -| `BULK_API_KEY` | leer | Bulk-API-Key | -| `BULK_API_KEY_FILE` | leer | Datei-Alternative für Bulk-API-Key | +Die Datei [`.env.example`](.env.example) ist ab Version 2.0.2 die **vollständige Referenz** aller vom offiziellen Server unterstützten Runtime-Variablen. Ein Regressionstest stellt sicher, dass die dokumentierten Kernvariablen nicht versehentlich aus der Beispieldatei verschwinden. + +Neu bzw. explizit dokumentiert sind insbesondere: + +- vertrauenswürdige Proxy-Netze über `TRUSTED_PROXY_CIDRS`; +- datensparsame Request-Logs über `LOG_CLIENT_IP=false`; +- optionales HSTS über `ENABLE_HSTS`; +- standardmäßig deaktivierte und per Bearer-Token geschützte Prometheus-Metriken; +- `_FILE`-Varianten für Lizenz-, Bulk- und Metrics-Secrets; +- vollständige Betreiber-, Datenschutz- und Barrierefreiheitsdaten für `/legal`, `/privacy` und `/accessibility`; +- `LEGAL_STRICT` als Fail-Fast-Prüfung gegen leere Produktions-Platzhalter. + +`SALES_URL` und die früheren `PRICE_*`-Variablen sind bewusst **nicht** mehr unterstützt: Preisübersicht und Pro-Werbung wurden aus der öffentlichen Anwendung entfernt. Tote Konfigurationsparameter werden nicht weitergeführt. + +Eine kommentierte Beschreibung aller Variablen steht zusätzlich in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md). ## Wichtige API-Endpunkte @@ -340,7 +340,10 @@ POST /v1/validate GET /v1/capabilities GET /healthz GET /readyz -GET /metrics +GET /metrics # optional, Bearer-geschützt +GET /legal # UI modes +GET /privacy # UI modes +GET /accessibility # UI modes ``` ## Sicherheit @@ -355,6 +358,10 @@ GET /metrics - Lizenz- und Bulk-Secrets können als Dateien gemountet werden; - CSP, `X-Content-Type-Options`, `X-Frame-Options`, Referrer- und Permissions-Policy; - konfigurierbares CORS; +- Proxy-Header werden nur von explizit vertrauenswürdigen CIDR-Netzen akzeptiert; +- Client-IP-Logging ist standardmäßig deaktiviert; +- optionales HSTS für HTTPS-only Deployments; +- Prometheus ist standardmäßig deaktiviert und bei Aktivierung Bearer-geschützt; - Request-IDs und strukturierte JSON-Logs; - Body- und Itemlimits für die Bulk-API; - optionale API-Key-Authentifizierung des Bulk-Endpunkts; diff --git a/SECURITY.md b/SECURITY.md index 28f1f3c..226e44f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,3 +19,15 @@ Do not open public issues containing active licence tokens, API keys, private in ## Trust store Only public issuer/lease keys from the operator's Universal License Platform belong in `internal/app/trusted_keys.json`. Never place private signing keys in this repository or image. + +## Proxy trust and client addresses + +`TRUST_PROXY=true` requires `TRUSTED_PROXY_CIDRS`. Forwarded address headers are used only when the directly connected peer belongs to one of those networks. Client IPs are omitted from request logs unless `LOG_CLIENT_IP=true` is explicitly enabled. + +## Prometheus + +`/metrics` is disabled by default. `METRICS_ENABLED=true` requires a bearer secret of at least 32 characters via `METRICS_TOKEN` or preferably `METRICS_TOKEN_FILE`. Do not expose the endpoint publicly even when authentication is enabled. + +## Operator information + +`LEGAL_STRICT=true` is a deployment guard against empty or placeholder operator/privacy configuration. It does not certify legal compliance and should be combined with a review of the actual deployment and applicable jurisdiction. diff --git a/compose.commercial.yaml b/compose.commercial.yaml index e9ca615..bcffb5f 100644 --- a/compose.commercial.yaml +++ b/compose.commercial.yaml @@ -5,8 +5,8 @@ services: context: . dockerfile: Dockerfile args: - VERSION: "2.0.0" - image: ai-disclosure-standard:2.0.0 + VERSION: "2.0.2" + image: ai-disclosure-standard:2.0.2 environment: BASE_URL: "${BASE_URL:?set BASE_URL}" OUTPUT_BASE_URL: "${BASE_URL:?set BASE_URL}" @@ -47,8 +47,8 @@ services: context: . dockerfile: Dockerfile.bulk args: - VERSION: "2.0.0" - image: ai-disclosure-standard-bulk:2.0.0 + VERSION: "2.0.2" + image: ai-disclosure-standard-bulk:2.0.2 environment: BASE_URL: "${BULK_BASE_URL:-http://bulk:8080}" OUTPUT_BASE_URL: "${BASE_URL:?set BASE_URL}" diff --git a/compose.yaml b/compose.yaml index 10d99e5..2dcfa36 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,8 +4,8 @@ services: context: . dockerfile: Dockerfile args: - VERSION: "2.0.0" - image: ai-disclosure-standard:2.0.0-local + VERSION: "2.0.2" + image: ai-disclosure-standard:2.0.2-local environment: BASE_URL: "${BASE_URL:-http://localhost:8080}" PUBLIC_NAME: "${PUBLIC_NAME:-AI Usage Disclosure}" diff --git a/deploy/kubernetes-bulk.yaml b/deploy/kubernetes-bulk.yaml index d44f809..057b90d 100644 --- a/deploy/kubernetes-bulk.yaml +++ b/deploy/kubernetes-bulk.yaml @@ -6,7 +6,7 @@ metadata: name: ai-disclosure-bulk labels: app.kubernetes.io/name: ai-disclosure-bulk - app.kubernetes.io/version: "2.0.0" + app.kubernetes.io/version: "2.0.2" spec: replicas: 3 strategy: @@ -21,7 +21,7 @@ spec: metadata: labels: app.kubernetes.io/name: ai-disclosure-bulk - app.kubernetes.io/version: "2.0.0" + app.kubernetes.io/version: "2.0.2" annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" @@ -34,7 +34,7 @@ spec: type: RuntimeDefault containers: - name: bulk - image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.0 + image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.2 imagePullPolicy: IfNotPresent ports: - name: http diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml index fbed20e..659c750 100644 --- a/deploy/kubernetes.yaml +++ b/deploy/kubernetes.yaml @@ -4,7 +4,7 @@ metadata: name: ai-disclosure labels: app.kubernetes.io/name: ai-disclosure - app.kubernetes.io/version: "2.0.0" + app.kubernetes.io/version: "2.0.2" spec: replicas: 3 strategy: @@ -19,7 +19,7 @@ spec: metadata: labels: app.kubernetes.io/name: ai-disclosure - app.kubernetes.io/version: "2.0.0" + app.kubernetes.io/version: "2.0.2" annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" @@ -32,7 +32,7 @@ spec: type: RuntimeDefault containers: - name: app - image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.0 + image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.2 imagePullPolicy: IfNotPresent ports: - name: http diff --git a/deploy/swarm-bulk-stack.yaml b/deploy/swarm-bulk-stack.yaml index 5ea0c45..b84016e 100644 --- a/deploy/swarm-bulk-stack.yaml +++ b/deploy/swarm-bulk-stack.yaml @@ -1,7 +1,7 @@ version: "3.9" services: bulk: - image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.0 + image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.2 environment: BASE_URL: http://bulk:8080 OUTPUT_BASE_URL: https://ai.example.org diff --git a/deploy/swarm-stack.yaml b/deploy/swarm-stack.yaml index 221652f..fe81c85 100644 --- a/deploy/swarm-stack.yaml +++ b/deploy/swarm-stack.yaml @@ -1,7 +1,7 @@ version: "3.9" services: app: - image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.0 + image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.2 environment: BASE_URL: https://ai.example.org PUBLIC_NAME: AI Usage Disclosure diff --git a/docs/BACKGROUND-PAGE.md b/docs/BACKGROUND-PAGE.md index f72abc3..8224ecf 100644 --- a/docs/BACKGROUND-PAGE.md +++ b/docs/BACKGROUND-PAGE.md @@ -1,6 +1,6 @@ # Mehrsprachige Hintergrundseite -Version 2.0.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit. +Version 2.0.2 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit. ## Routen diff --git a/docs/BULK-API.md b/docs/BULK-API.md index 8628432..a76ec08 100644 --- a/docs/BULK-API.md +++ b/docs/BULK-API.md @@ -1,6 +1,6 @@ # Bulk API -Der Bulk-Dienst ist für Publisher, CMS-Integrationen, Agenturen und andere Server-to-Server-Workflows gedacht. +Der Bulk-Dienst ist für Publisher, CMS-Integrationen, Agenturen und andere professionelle Workflows gedacht. Er stellt sowohl eine browserbasierte Bulk Workspace als auch die serverseitige API bereit. ## Capability @@ -29,7 +29,18 @@ BULK_REQUIRE_API_KEY=true API_ALLOWED_ORIGIN= ``` -Damit enthält die Angriffsoberfläche keine Generator-, Produkt- oder Hintergrundseite. +Der dedizierte Modus enthält bewusst keine öffentliche Generator-, Produkt- oder Hintergrundseite, stellt aber die spezialisierte Bulk Workspace unter `/` und `/bulk` bereit. Die eigentliche Verarbeitung erfolgt weiterhin über denselben authentifizierten API-Endpunkt. + +## Bulk Workspace + +Die browserbasierte Oberfläche ist erreichbar unter: + +```text +/ +/bulk +``` + +Sie bietet einen visuellen Mehrfach-Workflow, einen erweiterten JSON-Modus, Ergebnislinks sowie JSON-/CSV-Export. Details stehen in [`BULK-WORKSPACE.md`](BULK-WORKSPACE.md). ## Authentifizierung diff --git a/docs/BULK-WORKSPACE.md b/docs/BULK-WORKSPACE.md new file mode 100644 index 0000000..ed75add --- /dev/null +++ b/docs/BULK-WORKSPACE.md @@ -0,0 +1,69 @@ +# Bulk Workspace + +Der dedizierte Bulk-Modus stellt neben `POST /v1/bulk/declarations` wieder eine browserbasierte Arbeitsoberfläche bereit. + +## Routen + +```text +GET / +GET /bulk +POST /v1/bulk/declarations +``` + +Die Oberfläche ist für interne Publisher-, Agentur- und Enterprise-Workflows gedacht. Sie ersetzt die API nicht, sondern verwendet denselben Endpunkt. + +## Visueller Workflow + +Im Standardworkflow werden Inhalte zeilenweise angegeben: + +```text +article-1001 | https://example.org/articles/1001 +article-1002 | https://example.org/articles/1002 +``` + +Alternativ genügt eine URL pro Zeile. IDs werden dann automatisch erzeugt. + +Ein gemeinsames Artikelprofil kann u. a. festlegen: + +- Text, Titelbild, weitere Bilder, Recherche, Übersetzung und Code; +- KI-Anteil und menschliche Prüfung pro Bereich; +- Nachweisgrundlage; +- relevante Artikel-50-Angaben; +- redaktionelle Verantwortung. + +## Erweiterter JSON-Modus + +Heterogene Daten können als regulärer Bulk-Request eingefügt werden. Dadurch können einzelne Artikel vollständig unterschiedliche Parameter erhalten. + +## API-Key + +Ist `BULK_REQUIRE_API_KEY=true`, fragt die Oberfläche den Schlüssel lokal ab. Er wird nur im `sessionStorage` des aktuellen Browser-Tabs gehalten und als `Authorization: Bearer ...` an den Bulk-Endpunkt gesendet. + +Der Schlüssel wird nicht serverseitig in HTML eingebettet und nicht in Exportdateien übernommen. + +## Ergebnisse + +Die Oberfläche zeigt pro Datensatz: + +- ID; +- Validierungsstatus; +- technische Artikel-50-Einordnung; +- Link zur Erklärung; +- Link zum JSON-LD-Manifest; +- Badge-Link; +- Validierungsfehler. + +Das Gesamtergebnis kann als JSON oder CSV exportiert werden. + +## Sicherheitsmodell + +Der dedizierte Container behält die 2.0-Sicherheitsdefaults: + +```text +SERVICE_MODE=bulk +REQUIRE_LICENSE=true +BULK_REQUIRE_API_KEY=true +API_ALLOWED_ORIGIN= +``` + +Die Oberfläche ist deshalb kein Ersatz für Netzwerksegmentierung. Für externe Veröffentlichung sollte weiterhin ein TLS-Reverse-Proxy bzw. API-Gateway mit zusätzlicher Authentifizierung eingesetzt werden. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..22c59ca --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,94 @@ +# Runtime configuration + +`.env.example` is the canonical and complete reference for environment-based runtime configuration in version 2.0.2. + +The application deliberately does **not** support the former `SALES_URL` or `PRICE_*` variables. Pricing and Pro advertising were removed from the public UI; dead configuration keys are not kept for compatibility. + +## HTTP and identity + +- `LISTEN_ADDRESS` +- `BASE_URL` +- `OUTPUT_BASE_URL` +- `PUBLIC_NAME` +- `CONTACT_URL` +- `DEFAULT_LANGUAGE` +- `HEALTHCHECK_URL` +- `SERVICE_MODE` +- `API_ALLOWED_ORIGIN` + +## Proxy, logging and transport security + +- `TRUST_PROXY` +- `TRUSTED_PROXY_CIDRS` +- `LOG_CLIENT_IP` +- `ENABLE_HSTS` +- `REQUIRE_LICENSE` +- `WHITE_LABEL` + +`TRUST_PROXY=true` is rejected unless at least one valid CIDR is configured. Forwarded client-IP headers are only considered when the direct peer address is inside one of those networks. + +`LOG_CLIENT_IP=false` keeps client addresses out of structured request logs. + +`ENABLE_HSTS=true` emits `Strict-Transport-Security: max-age=31536000; includeSubDomains`; enable it only on deployments that are actually HTTPS-only. + +## Prometheus + +- `METRICS_ENABLED` +- `METRICS_TOKEN` +- `METRICS_TOKEN_FILE` + +Metrics are disabled by default. When enabled, a bearer token of at least 32 characters is mandatory. A configured `METRICS_TOKEN_FILE` has priority over `METRICS_TOKEN`. + +## Operator, privacy and accessibility pages + +The following settings feed `/legal`, `/privacy` and `/accessibility` in `full` and `bulk` mode: + +- `LEGAL_STRICT` +- `LEGAL_NAME` +- `LEGAL_ADDRESS` +- `LEGAL_REPRESENTATIVE` +- `LEGAL_EMAIL` +- `LEGAL_PHONE` +- `LEGAL_REGISTER` +- `LEGAL_REGISTER_NUMBER` +- `LEGAL_VAT_ID` +- `EDITORIAL_RESPONSIBLE_NAME` +- `EDITORIAL_RESPONSIBLE_ADDRESS` +- `DATA_PROTECTION_CONTACT` +- `HOSTING_PROVIDER` +- `HOSTING_ADDRESS` +- `LOG_RETENTION` +- `DATA_RECIPIENTS` +- `THIRD_COUNTRY_TRANSFERS` +- `SUPERVISORY_AUTHORITY_NAME` +- `SUPERVISORY_AUTHORITY_URL` +- `CONSUMER_DISPUTE_STATUS` +- `CONSUMER_DISPUTE_BODY` +- `CONSUMER_DISPUTE_URL` +- `ACCESSIBILITY_CONTACT` +- `ACCESSIBILITY_STATUS` + +`LEGAL_STRICT=true` is a deployment guard, not a legal-compliance determination. It prevents startup if the core operator/privacy data is empty or still contains common placeholder markers. The pages themselves explicitly state that they do not replace legal review. + +## Runtime licensing + +- `LICENSE_TOKEN` +- `LICENSE_TOKEN_FILE` +- `LICENSE_INSTANCE_ID` +- `LICENSE_MODE` +- `LICENSE_SERVER_URL` +- `LICENSE_CACHE_FILE` +- `LICENSE_REFRESH_INTERVAL` +- `LICENSE_REQUEST_TIMEOUT` + +The application contains no licence issuer, private signing key or configurable issuer public key. Licence creation remains external in the Universal License Platform. + +## Bulk + +- `BULK_MAX_ITEMS` +- `BULK_MAX_BODY_BYTES` +- `BULK_REQUIRE_API_KEY` +- `BULK_API_KEY` +- `BULK_API_KEY_FILE` + +The `bulk_items` licence limit can further reduce the effective item count. A configured `BULK_API_KEY_FILE` has priority over `BULK_API_KEY`. diff --git a/docs/LICENSE-CLIENT.md b/docs/LICENSE-CLIENT.md index ab219a7..10e5817 100644 --- a/docs/LICENSE-CLIENT.md +++ b/docs/LICENSE-CLIENT.md @@ -16,7 +16,7 @@ Initialization is performed in `internal/app/server.go`. The product ID and embe ```go licenses := licenseclient.New(ctx, licenseclient.Config{ Product: "ai-disclosure-standard", - ClientVersion: "2.0.0", + ClientVersion: "2.0.2", Token: cfg.LicenseToken, TrustStore: trustStore, BaseURL: cfg.BaseURL, diff --git a/docs/LICENSE-INTEGRATION.md b/docs/LICENSE-INTEGRATION.md index 03616c6..14ce3f6 100644 --- a/docs/LICENSE-INTEGRATION.md +++ b/docs/LICENSE-INTEGRATION.md @@ -119,7 +119,7 @@ Anfrage: "baseUrl": "https://ai.example.org", "host": "ai.example.org", "instanceId": "production-eu-1", - "clientVersion": "2.0.0" + "clientVersion": "2.0.2" } ``` diff --git a/docs/OPERATOR-PAGES.md b/docs/OPERATOR-PAGES.md new file mode 100644 index 0000000..5ae0d43 --- /dev/null +++ b/docs/OPERATOR-PAGES.md @@ -0,0 +1,13 @@ +# Operator, privacy and accessibility pages + +Version 2.0.2 provides multilingual operator pages in `full` and `bulk` mode: + +- `/legal` (`/imprint` is an alias) +- `/privacy` +- `/accessibility` + +All eight application languages are supported through `?lang=de`, `?lang=en`, `?lang=fr`, `?lang=es`, `?lang=it`, `?lang=nl`, `?lang=pt` and `?lang=pl`. Each page emits language-alternate metadata. + +These routes remain reachable when `REQUIRE_LICENSE=true` and the runtime licence is unavailable, so legally relevant operator information does not disappear because of a licensing incident. Their static assets are exempt for the same reason. + +The content is generated from environment configuration. Empty optional fields are not rendered. `LEGAL_STRICT` can be used as a production deployment guard, but it does not assert that every field required by every jurisdiction is present. diff --git a/internal/app/config.go b/internal/app/config.go index 786aa3a..e09dae2 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -1,6 +1,8 @@ package app import ( + "fmt" + "net/netip" "os" "strconv" "strings" @@ -17,6 +19,9 @@ type Config struct { ContactURL string DefaultLanguage string TrustProxy bool + TrustedProxies []netip.Prefix + LogClientIP bool + EnableHSTS bool ServiceMode string BulkMaxItems int BulkMaxBodyBytes int64 @@ -26,6 +31,34 @@ type Config struct { RequireLicense bool WhiteLabel 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 LicenseServerURL string @@ -49,15 +82,46 @@ func ConfigFromEnv() Config { PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"), ContactURL: contactURL, DefaultLanguage: env("DEFAULT_LANGUAGE", "de"), - TrustProxy: strings.EqualFold(env("TRUST_PROXY", "false"), "true"), + TrustProxy: boolEnv("TRUST_PROXY", false), + TrustedProxies: cidrEnv("TRUSTED_PROXY_CIDRS"), + LogClientIP: boolEnv("LOG_CLIENT_IP", false), + EnableHSTS: boolEnv("ENABLE_HSTS", false), ServiceMode: serviceModeEnv("SERVICE_MODE", "full"), BulkMaxItems: intEnv("BULK_MAX_ITEMS", 500), BulkMaxBodyBytes: int64(intEnv("BULK_MAX_BODY_BYTES", 4<<20)), - BulkRequireAPIKey: strings.EqualFold(env("BULK_REQUIRE_API_KEY", "false"), "true"), + BulkRequireAPIKey: boolEnv("BULK_REQUIRE_API_KEY", false), BulkAPIKey: secretEnv("BULK_API_KEY"), APIAllowedOrigin: envAllowEmpty("API_ALLOWED_ORIGIN", "*"), - RequireLicense: strings.EqualFold(env("REQUIRE_LICENSE", "false"), "true"), - WhiteLabel: strings.EqualFold(env("WHITE_LABEL", "false"), "true"), + RequireLicense: boolEnv("REQUIRE_LICENSE", false), + WhiteLabel: boolEnv("WHITE_LABEL", false), + + MetricsEnabled: boolEnv("METRICS_ENABLED", false), + MetricsToken: secretEnv("METRICS_TOKEN"), + + LegalStrict: boolEnv("LEGAL_STRICT", false), + LegalName: envAllowEmpty("LEGAL_NAME", ""), + LegalAddress: envAllowEmpty("LEGAL_ADDRESS", ""), + LegalRepresentative: envAllowEmpty("LEGAL_REPRESENTATIVE", ""), + LegalEmail: envAllowEmpty("LEGAL_EMAIL", ""), + LegalPhone: envAllowEmpty("LEGAL_PHONE", ""), + LegalRegister: envAllowEmpty("LEGAL_REGISTER", ""), + LegalRegisterNumber: envAllowEmpty("LEGAL_REGISTER_NUMBER", ""), + LegalVATID: envAllowEmpty("LEGAL_VAT_ID", ""), + EditorialResponsibleName: envAllowEmpty("EDITORIAL_RESPONSIBLE_NAME", ""), + EditorialResponsibleAddress: envAllowEmpty("EDITORIAL_RESPONSIBLE_ADDRESS", ""), + DataProtectionContact: envAllowEmpty("DATA_PROTECTION_CONTACT", ""), + HostingProvider: envAllowEmpty("HOSTING_PROVIDER", ""), + HostingAddress: envAllowEmpty("HOSTING_ADDRESS", ""), + LogRetention: envAllowEmpty("LOG_RETENTION", ""), + DataRecipients: envAllowEmpty("DATA_RECIPIENTS", ""), + ThirdCountryTransfers: envAllowEmpty("THIRD_COUNTRY_TRANSFERS", ""), + SupervisoryAuthorityName: envAllowEmpty("SUPERVISORY_AUTHORITY_NAME", ""), + SupervisoryAuthorityURL: envAllowEmpty("SUPERVISORY_AUTHORITY_URL", ""), + ConsumerDisputeStatus: disputeStatusEnv("CONSUMER_DISPUTE_STATUS", "not_applicable"), + ConsumerDisputeBody: envAllowEmpty("CONSUMER_DISPUTE_BODY", ""), + ConsumerDisputeURL: envAllowEmpty("CONSUMER_DISPUTE_URL", ""), + AccessibilityContact: envAllowEmpty("ACCESSIBILITY_CONTACT", ""), + AccessibilityStatus: envAllowEmpty("ACCESSIBILITY_STATUS", ""), LicenseToken: secretEnv("LICENSE_TOKEN"), LicenseMode: mode, @@ -69,6 +133,38 @@ func ConfigFromEnv() Config { } } +func (c Config) Validate() error { + if c.TrustProxy && len(c.TrustedProxies) == 0 { + return fmt.Errorf("TRUST_PROXY=true requires at least one valid TRUSTED_PROXY_CIDRS entry") + } + if c.MetricsEnabled && len(c.MetricsToken) < 32 { + return fmt.Errorf("METRICS_ENABLED=true requires METRICS_TOKEN or METRICS_TOKEN_FILE with at least 32 characters") + } + if c.LegalStrict { + required := map[string]string{ + "LEGAL_NAME": c.LegalName, + "LEGAL_ADDRESS": c.LegalAddress, + "LEGAL_EMAIL": c.LegalEmail, + "DATA_PROTECTION_CONTACT": c.DataProtectionContact, + "HOSTING_PROVIDER": c.HostingProvider, + "HOSTING_ADDRESS": c.HostingAddress, + } + missing := make([]string, 0) + for key, value := range required { + if placeholderOrEmpty(value) { + missing = append(missing, key) + } + } + if len(missing) > 0 { + return fmt.Errorf("LEGAL_STRICT=true but required operator fields are missing or placeholders: %s", strings.Join(missing, ", ")) + } + if c.ConsumerDisputeStatus == "participating" && (placeholderOrEmpty(c.ConsumerDisputeBody) || placeholderOrEmpty(c.ConsumerDisputeURL)) { + return fmt.Errorf("CONSUMER_DISPUTE_STATUS=participating requires CONSUMER_DISPUTE_BODY and CONSUMER_DISPUTE_URL") + } + } + return nil +} + func env(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value @@ -85,18 +181,29 @@ func envAllowEmpty(key, fallback string) string { } func secretEnv(key string) string { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } + // Explicit *_FILE configuration is authoritative. This avoids a stale + // environment value silently overriding Docker/Kubernetes secrets. path := strings.TrimSpace(os.Getenv(key + "_FILE")) - if path == "" { - return "" + if path != "" { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) } - data, err := os.ReadFile(path) + return strings.TrimSpace(os.Getenv(key)) +} + +func boolEnv(key string, fallback bool) bool { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) if err != nil { - return "" + return fallback } - return strings.TrimSpace(string(data)) + return parsed } func durationEnv(key string, fallback time.Duration) time.Duration { @@ -136,3 +243,47 @@ func serviceModeEnv(key, fallback string) string { return fallback } } + +func disputeStatusEnv(key, fallback string) string { + value := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + switch value { + case "not_applicable", "not_participating", "participating": + return value + case "": + return fallback + default: + return fallback + } +} + +func cidrEnv(key string) []netip.Prefix { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]netip.Prefix, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if prefix, err := netip.ParsePrefix(part); err == nil { + out = append(out, prefix.Masked()) + continue + } + if addr, err := netip.ParseAddr(part); err == nil { + out = append(out, netip.PrefixFrom(addr, addr.BitLen())) + } + } + return out +} + +func placeholderOrEmpty(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return true + } + upper := strings.ToUpper(trimmed) + return strings.Contains(upper, "REPLACE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "TODO") +} diff --git a/internal/app/config_test.go b/internal/app/config_test.go new file mode 100644 index 0000000..e594587 --- /dev/null +++ b/internal/app/config_test.go @@ -0,0 +1,157 @@ +package app + +import ( + "go/ast" + "go/parser" + "go/token" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +func TestConfigValidationRequiresTrustedProxyCIDRs(t *testing.T) { + cfg := Config{TrustProxy: true} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXY_CIDRS") { + t.Fatalf("expected trusted proxy validation error, got %v", err) + } +} + +func TestConfigValidationRequiresMetricsToken(t *testing.T) { + cfg := Config{MetricsEnabled: true, MetricsToken: "short"} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "METRICS_TOKEN") { + t.Fatalf("expected metrics token validation error, got %v", err) + } +} + +func TestConfigValidationLegalStrict(t *testing.T) { + cfg := Config{LegalStrict: true} + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "LEGAL_NAME") { + t.Fatalf("expected legal validation error, got %v", err) + } + cfg.LegalName = "Example GmbH" + cfg.LegalAddress = "Example Street 1\n12345 Example" + cfg.LegalEmail = "legal@example.org" + cfg.DataProtectionContact = "privacy@example.org" + cfg.HostingProvider = "Example Hosting GmbH" + cfg.HostingAddress = "Hosting Street 1\n12345 Example" + if err := cfg.Validate(); err != nil { + t.Fatalf("valid strict legal configuration rejected: %v", err) + } +} + +func TestMetricsDisabledAndProtected(t *testing.T) { + h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de"}) + r := httptest.NewRequest(http.MethodGet, "/metrics", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("disabled metrics status %d", w.Code) + } + + cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true, MetricsToken: strings.Repeat("x", 40)} + h = testHandlerConfig(t, cfg) + r = httptest.NewRequest(http.MethodGet, "/metrics", nil) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("metrics without token status %d", w.Code) + } + r = httptest.NewRequest(http.MethodGet, "/metrics", nil) + r.Header.Set("Authorization", "Bearer "+cfg.MetricsToken) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "ai_disclosure_uptime_seconds") { + t.Fatalf("authorized metrics failed: %d %s", w.Code, w.Body.String()) + } +} + +func TestOperatorPagesAndHSTS(t *testing.T) { + cfg := Config{ + ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Example", DefaultLanguage: "de", EnableHSTS: true, + LegalName: "Example GmbH", LegalAddress: "Example Street 1", LegalEmail: "legal@example.org", + DataProtectionContact: "privacy@example.org", HostingProvider: "Host GmbH", HostingAddress: "Host Street 1", + AccessibilityContact: "access@example.org", AccessibilityStatus: "Teilweise konform getestet", + } + h := testHandlerConfig(t, cfg) + for _, tc := range []struct{ path, want string }{{"/legal?lang=de", "Example GmbH"}, {"/privacy?lang=en", "Privacy information"}, {"/accessibility?lang=fr", "Accessibilité"}} { + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), tc.want) { + t.Fatalf("%s: %d, missing %q", tc.path, w.Code, tc.want) + } + if got := w.Header().Get("Strict-Transport-Security"); got == "" { + t.Fatalf("%s missing HSTS", tc.path) + } + } +} + +func TestEnvExampleDocumentsRuntimeConfiguration(t *testing.T) { + root := filepath.Join("..", "..") + data, err := os.ReadFile(filepath.Join(root, ".env.example")) + if err != nil { + t.Fatal(err) + } + text := string(data) + keys := map[string]struct{}{} + files := []string{filepath.Join("config.go"), filepath.Join(root, "cmd", "server", "main.go")} + helpers := map[string]bool{ + "env": true, "envAllowEmpty": true, "boolEnv": true, "durationEnv": true, "intEnv": true, + "serviceModeEnv": true, "disputeStatusEnv": true, "cidrEnv": true, "secretEnv": true, + } + fset := token.NewFileSet() + for _, filename := range files { + file, err := parser.ParseFile(fset, filename, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", filename, err) + } + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + name := "" + switch fn := call.Fun.(type) { + case *ast.Ident: + if helpers[fn.Name] { + name = fn.Name + } + case *ast.SelectorExpr: + if ident, ok := fn.X.(*ast.Ident); ok && ident.Name == "os" && (fn.Sel.Name == "Getenv" || fn.Sel.Name == "LookupEnv") { + name = fn.Sel.Name + } + } + if name == "" { + return true + } + lit, ok := call.Args[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + key, err := strconv.Unquote(lit.Value) + if err != nil || key == "" { + return true + } + keys[key] = struct{}{} + if name == "secretEnv" { + keys[key+"_FILE"] = struct{}{} + } + return true + }) + } + missing := make([]string, 0) + for key := range keys { + if !strings.Contains(text, key+"=") { + missing = append(missing, key) + } + } + sort.Strings(missing) + if len(missing) > 0 { + t.Fatalf(".env.example is missing runtime variables: %s", strings.Join(missing, ", ")) + } +} diff --git a/internal/app/legal.go b/internal/app/legal.go new file mode 100644 index 0000000..c893bc5 --- /dev/null +++ b/internal/app/legal.go @@ -0,0 +1,314 @@ +package app + +import ( + "net/http" + "strings" + + "github.com/b1tsblog/ai-disclosure-standard/internal/i18n" +) + +type legalItem struct { + Label string + Value string + URL string +} + +type legalSection struct { + Title string + Paragraphs []string + Items []legalItem +} + +type legalPage struct { + Kind string + Title string + Lead string + Sections []legalSection + NoticeTitle string + Notice string +} + +type legalCopy struct { + LegalLabel, PrivacyLabel, AccessibilityLabel string + LegalTitle, LegalLead string + Operator, Representative, Contact string + Address, Email, Phone, Register, RegisterNo string + VAT, Editorial, EditorialAddress string + DisputeTitle, DisputeNA, DisputeNo, DisputeYes string + PrivacyTitle, PrivacyLead string + Controller, DataProtection, Hosting string + HostingAddress, Logs, Retention, Recipients string + Transfers, Authority string + PrivacyTechTitle, PrivacyTechText string + AccessibilityTitle, AccessibilityLead string + AccessibilityContact, AccessibilityStatus string + AccessibilityTechTitle, AccessibilityTechText string + NoticeTitle, Notice string +} + +func legalLabels(lang string) legalCopy { + base := legalCopy{ + LegalLabel: "Legal notice", PrivacyLabel: "Privacy", AccessibilityLabel: "Accessibility", + LegalTitle: "Legal notice", LegalLead: "Operator and contact information for this service.", + Operator: "Operator", Representative: "Represented by", Contact: "Contact", Address: "Address", Email: "Email", Phone: "Phone", Register: "Register", RegisterNo: "Register number", VAT: "VAT ID", Editorial: "Editorial responsibility", EditorialAddress: "Address of editorially responsible person", + DisputeTitle: "Consumer dispute resolution", DisputeNA: "The operator has assessed the general information obligation as not applicable to this service.", DisputeNo: "The operator states that it does not participate in consumer dispute resolution proceedings unless legally required otherwise.", DisputeYes: "The operator states that it participates in consumer dispute resolution proceedings before the body listed below.", + PrivacyTitle: "Privacy information", PrivacyLead: "Information about the operation of this service and the processing of technical connection data.", + Controller: "Controller / operator", DataProtection: "Data protection contact", Hosting: "Hosting provider", HostingAddress: "Hosting address", Logs: "Server logs", Retention: "Log retention", Recipients: "Recipients / processors", Transfers: "Third-country transfers", Authority: "Supervisory authority", + PrivacyTechTitle: "Technical design", PrivacyTechText: "The application itself does not require advertising trackers or third-party analytics. Depending on the deployment, the reverse proxy, hosting platform or surrounding infrastructure may process connection and log data. The operator must keep the information on this page aligned with the actual deployment.", + AccessibilityTitle: "Accessibility", AccessibilityLead: "Information and contact options relating to the accessibility of this service.", AccessibilityContact: "Accessibility contact", AccessibilityStatus: "Accessibility status", + AccessibilityTechTitle: "Technical approach", AccessibilityTechText: "The application uses semantic HTML, keyboard-operable controls and language metadata. This statement is not a certification. The operator remains responsible for testing the deployed service, custom content, branding and integrations against the requirements applicable to it.", + NoticeTitle: "Important", Notice: "These pages are configurable operator information, not legal advice. Their completeness and legal applicability depend on the operator, jurisdiction, business model and actual technical deployment.", + } + switch normalizeLanguage(lang) { + case "de": + base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Impressum", "Datenschutz", "Barrierefreiheit" + base.LegalTitle, base.LegalLead = "Impressum und Anbieterinformationen", "Angaben zum Betreiber und zu den Kontaktmöglichkeiten dieses Dienstes." + base.Operator, base.Representative, base.Contact = "Anbieter / Betreiber", "Vertreten durch", "Kontakt" + base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Anschrift", "E-Mail", "Telefon", "Register", "Registernummer", "Umsatzsteuer-ID" + base.Editorial, base.EditorialAddress = "Journalistisch-redaktionell verantwortlich", "Anschrift der verantwortlichen Person" + base.DisputeTitle = "Verbraucherstreitbeilegung" + base.DisputeNA = "Der Betreiber hat die allgemeine Informationspflicht zur Verbraucherstreitbeilegung für dieses Angebot als nicht anwendbar eingeordnet." + base.DisputeNo = "Der Betreiber erklärt, nicht an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen, soweit keine gesetzliche Verpflichtung besteht." + base.DisputeYes = "Der Betreiber erklärt, an Streitbeilegungsverfahren vor der nachfolgend genannten Verbraucherschlichtungsstelle teilzunehmen." + base.PrivacyTitle, base.PrivacyLead = "Datenschutzhinweise", "Informationen zum Betrieb dieses Dienstes und zur Verarbeitung technischer Verbindungsdaten." + base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Verantwortlicher / Betreiber", "Datenschutzkontakt", "Hosting-Anbieter", "Anschrift des Hosting-Anbieters" + base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Server-Protokolle", "Speicherdauer der Protokolle", "Empfänger / Auftragsverarbeiter", "Drittlandübermittlungen", "Aufsichtsbehörde" + base.PrivacyTechTitle = "Technische Ausgestaltung" + base.PrivacyTechText = "Die Anwendung selbst benötigt keine Werbetracker oder Drittanbieter-Analytics. Je nach Deployment können Reverse Proxy, Hosting-Plattform oder umgebende Infrastruktur Verbindungs- und Protokolldaten verarbeiten. Der Betreiber muss die Angaben auf dieser Seite mit dem tatsächlichen Betrieb abgleichen." + base.AccessibilityTitle, base.AccessibilityLead = "Barrierefreiheit", "Informationen und Kontaktmöglichkeiten zur Barrierefreiheit dieses Dienstes." + base.AccessibilityContact, base.AccessibilityStatus = "Kontakt zur Barrierefreiheit", "Stand der Barrierefreiheit" + base.AccessibilityTechTitle = "Technischer Ansatz" + base.AccessibilityTechText = "Die Anwendung verwendet semantisches HTML, tastaturbedienbare Steuerelemente und Sprachmetadaten. Diese Angabe ist keine Zertifizierung. Der Betreiber bleibt dafür verantwortlich, das konkrete Deployment, eigene Inhalte, Branding und Integrationen anhand der für ihn geltenden Anforderungen zu prüfen." + base.NoticeTitle, base.Notice = "Wichtiger Hinweis", "Diese Seiten stellen konfigurierbare Betreiberinformationen und keine Rechtsberatung dar. Vollständigkeit und rechtliche Anwendbarkeit hängen vom Betreiber, der Rechtsordnung, dem Geschäftsmodell und dem tatsächlichen technischen Betrieb ab." + case "fr": + base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Mentions légales", "Confidentialité", "Accessibilité" + base.LegalTitle, base.LegalLead = "Mentions légales", "Informations sur l’exploitant et les moyens de contact de ce service." + base.Operator, base.Representative, base.Contact = "Exploitant", "Représenté par", "Contact" + base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adresse", "E-mail", "Téléphone", "Registre", "Numéro d’enregistrement", "Numéro de TVA" + base.Editorial, base.EditorialAddress = "Responsabilité éditoriale", "Adresse de la personne responsable" + base.DisputeTitle, base.DisputeNA = "Règlement des litiges de consommation", "L’exploitant a considéré que l’obligation générale d’information relative au règlement des litiges de consommation n’est pas applicable à ce service." + base.DisputeNo = "L’exploitant déclare ne pas participer à une procédure de règlement des litiges de consommation, sauf obligation légale contraire." + base.DisputeYes = "L’exploitant déclare participer à une procédure de règlement des litiges devant l’organisme indiqué ci-dessous." + base.PrivacyTitle, base.PrivacyLead = "Informations sur la confidentialité", "Informations sur l’exploitation du service et le traitement des données techniques de connexion." + base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsable / exploitant", "Contact protection des données", "Hébergeur", "Adresse de l’hébergeur" + base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Journaux serveur", "Durée de conservation des journaux", "Destinataires / sous-traitants", "Transferts vers des pays tiers", "Autorité de contrôle" + base.PrivacyTechTitle, base.PrivacyTechText = "Conception technique", "L’application elle-même ne nécessite ni traceurs publicitaires ni outils d’analyse tiers. Selon le déploiement, le proxy inverse, l’hébergement ou l’infrastructure environnante peuvent traiter des données de connexion et de journalisation. L’exploitant doit maintenir ces informations conformes au fonctionnement réel." + base.AccessibilityTitle, base.AccessibilityLead = "Accessibilité", "Informations et contact concernant l’accessibilité de ce service." + base.AccessibilityContact, base.AccessibilityStatus = "Contact accessibilité", "État d’accessibilité" + base.AccessibilityTechTitle, base.AccessibilityTechText = "Approche technique", "L’application utilise du HTML sémantique, des contrôles utilisables au clavier et des métadonnées de langue. Cette indication n’est pas une certification. L’exploitant reste responsable de l’évaluation du déploiement, des contenus personnalisés, de l’identité visuelle et des intégrations." + base.NoticeTitle, base.Notice = "Remarque importante", "Ces pages fournissent des informations configurables sur l’exploitant et ne constituent pas un conseil juridique. Leur exhaustivité dépend du déploiement et du droit applicable." + case "es": + base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Aviso legal", "Privacidad", "Accesibilidad" + base.LegalTitle, base.LegalLead = "Aviso legal", "Información sobre el operador y los medios de contacto de este servicio." + base.Operator, base.Representative, base.Contact = "Operador", "Representado por", "Contacto" + base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Dirección", "Correo electrónico", "Teléfono", "Registro", "Número registral", "NIF-IVA" + base.Editorial, base.EditorialAddress = "Responsabilidad editorial", "Dirección de la persona responsable" + base.DisputeTitle, base.DisputeNA = "Resolución de litigios de consumo", "El operador ha considerado que la obligación general de información sobre resolución de litigios de consumo no es aplicable a este servicio." + base.DisputeNo = "El operador declara que no participa en procedimientos de resolución de litigios de consumo salvo obligación legal." + base.DisputeYes = "El operador declara que participa en procedimientos de resolución de litigios ante el organismo indicado a continuación." + base.PrivacyTitle, base.PrivacyLead = "Información de privacidad", "Información sobre la operación del servicio y el tratamiento de datos técnicos de conexión." + base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsable / operador", "Contacto de protección de datos", "Proveedor de alojamiento", "Dirección del proveedor de alojamiento" + base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Registros del servidor", "Conservación de registros", "Destinatarios / encargados", "Transferencias a terceros países", "Autoridad de control" + base.PrivacyTechTitle, base.PrivacyTechText = "Diseño técnico", "La aplicación no requiere rastreadores publicitarios ni analítica de terceros. Según el despliegue, el proxy inverso, el alojamiento o la infraestructura circundante pueden procesar datos de conexión y registros. El operador debe mantener esta información alineada con el funcionamiento real." + base.AccessibilityTitle, base.AccessibilityLead = "Accesibilidad", "Información y contacto sobre la accesibilidad de este servicio." + base.AccessibilityContact, base.AccessibilityStatus = "Contacto de accesibilidad", "Estado de accesibilidad" + base.AccessibilityTechTitle, base.AccessibilityTechText = "Enfoque técnico", "La aplicación utiliza HTML semántico, controles operables mediante teclado y metadatos de idioma. Esta información no es una certificación. El operador sigue siendo responsable de evaluar el despliegue, el contenido personalizado, la marca y las integraciones." + base.NoticeTitle, base.Notice = "Aviso importante", "Estas páginas contienen información configurable del operador y no constituyen asesoramiento jurídico. Su suficiencia depende del despliegue y de la normativa aplicable." + case "it": + base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Note legali", "Privacy", "Accessibilità" + base.LegalTitle, base.LegalLead = "Note legali", "Informazioni sul gestore e sui contatti di questo servizio." + base.Operator, base.Representative, base.Contact = "Gestore", "Rappresentato da", "Contatto" + base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Indirizzo", "E-mail", "Telefono", "Registro", "Numero di registrazione", "Partita IVA" + base.Editorial, base.EditorialAddress = "Responsabilità editoriale", "Indirizzo del responsabile editoriale" + base.DisputeTitle, base.DisputeNA = "Risoluzione delle controversie dei consumatori", "Il gestore ha valutato come non applicabile a questo servizio l’obbligo generale di informazione sulla risoluzione delle controversie dei consumatori." + base.DisputeNo = "Il gestore dichiara di non partecipare a procedure di risoluzione delle controversie dei consumatori salvo diverso obbligo di legge." + base.DisputeYes = "Il gestore dichiara di partecipare a procedure di risoluzione delle controversie dinanzi all’organismo indicato di seguito." + base.PrivacyTitle, base.PrivacyLead = "Informativa privacy", "Informazioni sul funzionamento del servizio e sul trattamento dei dati tecnici di connessione." + base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Titolare / gestore", "Contatto privacy", "Fornitore di hosting", "Indirizzo del fornitore di hosting" + base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Log del server", "Conservazione dei log", "Destinatari / responsabili", "Trasferimenti verso paesi terzi", "Autorità di controllo" + base.PrivacyTechTitle, base.PrivacyTechText = "Configurazione tecnica", "L’applicazione non richiede tracker pubblicitari o analytics di terze parti. A seconda del deployment, reverse proxy, hosting o infrastruttura circostante possono trattare dati di connessione e log. Il gestore deve mantenere queste informazioni coerenti con il funzionamento effettivo." + base.AccessibilityTitle, base.AccessibilityLead = "Accessibilità", "Informazioni e contatti relativi all’accessibilità di questo servizio." + base.AccessibilityContact, base.AccessibilityStatus = "Contatto accessibilità", "Stato di accessibilità" + base.AccessibilityTechTitle, base.AccessibilityTechText = "Approccio tecnico", "L’applicazione utilizza HTML semantico, controlli utilizzabili da tastiera e metadati linguistici. Questa indicazione non è una certificazione. Il gestore resta responsabile della verifica del deployment, dei contenuti personalizzati, del branding e delle integrazioni." + base.NoticeTitle, base.Notice = "Avviso importante", "Queste pagine contengono informazioni configurabili del gestore e non costituiscono consulenza legale. La loro completezza dipende dal deployment e dalla normativa applicabile." + case "nl": + base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Juridische informatie", "Privacy", "Toegankelijkheid" + base.LegalTitle, base.LegalLead = "Juridische informatie", "Informatie over de exploitant en contactmogelijkheden van deze dienst." + base.Operator, base.Representative, base.Contact = "Exploitant", "Vertegenwoordigd door", "Contact" + base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adres", "E-mail", "Telefoon", "Register", "Registratienummer", "Btw-nummer" + base.Editorial, base.EditorialAddress = "Redactionele verantwoordelijkheid", "Adres van de redactioneel verantwoordelijke" + base.DisputeTitle, base.DisputeNA = "Consumentengeschillen", "De exploitant heeft beoordeeld dat de algemene informatieplicht over consumentengeschillen niet van toepassing is op deze dienst." + base.DisputeNo = "De exploitant verklaart niet deel te nemen aan procedures voor consumentengeschillen, tenzij dit wettelijk verplicht is." + base.DisputeYes = "De exploitant verklaart deel te nemen aan procedures voor consumentengeschillen bij de hieronder genoemde instantie." + base.PrivacyTitle, base.PrivacyLead = "Privacy-informatie", "Informatie over de werking van de dienst en verwerking van technische verbindingsgegevens." + base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Verwerkingsverantwoordelijke / exploitant", "Privacycontact", "Hostingprovider", "Adres van hostingprovider" + base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Serverlogs", "Bewaartermijn logs", "Ontvangers / verwerkers", "Doorgiften naar derde landen", "Toezichthouder" + base.PrivacyTechTitle, base.PrivacyTechText = "Technische inrichting", "De toepassing zelf vereist geen advertentietrackers of externe analytics. Afhankelijk van de deployment kunnen reverse proxy, hostingplatform of omliggende infrastructuur verbindings- en loggegevens verwerken. De exploitant moet deze informatie laten aansluiten op de werkelijke werking." + base.AccessibilityTitle, base.AccessibilityLead = "Toegankelijkheid", "Informatie en contact over de toegankelijkheid van deze dienst." + base.AccessibilityContact, base.AccessibilityStatus = "Contact toegankelijkheid", "Toegankelijkheidsstatus" + base.AccessibilityTechTitle, base.AccessibilityTechText = "Technische aanpak", "De toepassing gebruikt semantische HTML, toetsenbordbedienbare besturingselementen en taalmetadata. Dit is geen certificering. De exploitant blijft verantwoordelijk voor het toetsen van deployment, aangepaste inhoud, huisstijl en integraties." + base.NoticeTitle, base.Notice = "Belangrijk", "Deze pagina’s bevatten configureerbare exploitantinformatie en zijn geen juridisch advies. Volledigheid hangt af van deployment en toepasselijk recht." + case "pt": + base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Informação legal", "Privacidade", "Acessibilidade" + base.LegalTitle, base.LegalLead = "Informação legal", "Informações sobre o operador e os contactos deste serviço." + base.Operator, base.Representative, base.Contact = "Operador", "Representado por", "Contacto" + base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Morada", "E-mail", "Telefone", "Registo", "Número de registo", "N.º de IVA" + base.Editorial, base.EditorialAddress = "Responsabilidade editorial", "Morada da pessoa editorialmente responsável" + base.DisputeTitle, base.DisputeNA = "Resolução de litígios de consumo", "O operador avaliou que a obrigação geral de informação sobre resolução de litígios de consumo não se aplica a este serviço." + base.DisputeNo = "O operador declara que não participa em procedimentos de resolução de litígios de consumo, salvo obrigação legal." + base.DisputeYes = "O operador declara que participa em procedimentos de resolução de litígios perante a entidade indicada abaixo." + base.PrivacyTitle, base.PrivacyLead = "Informação de privacidade", "Informações sobre o funcionamento do serviço e o tratamento de dados técnicos de ligação." + base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsável / operador", "Contacto de proteção de dados", "Fornecedor de alojamento", "Morada do fornecedor de alojamento" + base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Registos do servidor", "Conservação dos registos", "Destinatários / subcontratantes", "Transferências para países terceiros", "Autoridade de controlo" + base.PrivacyTechTitle, base.PrivacyTechText = "Configuração técnica", "A aplicação não necessita de rastreadores publicitários nem de analytics de terceiros. Dependendo do deployment, o proxy inverso, o alojamento ou a infraestrutura envolvente podem tratar dados de ligação e registos. O operador deve manter esta informação alinhada com o funcionamento real." + base.AccessibilityTitle, base.AccessibilityLead = "Acessibilidade", "Informações e contacto sobre a acessibilidade deste serviço." + base.AccessibilityContact, base.AccessibilityStatus = "Contacto de acessibilidade", "Estado de acessibilidade" + base.AccessibilityTechTitle, base.AccessibilityTechText = "Abordagem técnica", "A aplicação utiliza HTML semântico, controlos operáveis por teclado e metadados de idioma. Esta indicação não é uma certificação. O operador continua responsável por testar o deployment, os conteúdos personalizados, a marca e as integrações." + base.NoticeTitle, base.Notice = "Aviso importante", "Estas páginas contêm informações configuráveis do operador e não constituem aconselhamento jurídico. A suficiência depende do deployment e da legislação aplicável." + case "pl": + base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Informacje prawne", "Prywatność", "Dostępność" + base.LegalTitle, base.LegalLead = "Informacje prawne", "Informacje o operatorze i możliwościach kontaktu z usługą." + base.Operator, base.Representative, base.Contact = "Operator", "Reprezentowany przez", "Kontakt" + base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adres", "E-mail", "Telefon", "Rejestr", "Numer rejestracyjny", "NIP UE" + base.Editorial, base.EditorialAddress = "Odpowiedzialność redakcyjna", "Adres osoby odpowiedzialnej redakcyjnie" + base.DisputeTitle, base.DisputeNA = "Rozwiązywanie sporów konsumenckich", "Operator ocenił, że ogólny obowiązek informacyjny dotyczący rozwiązywania sporów konsumenckich nie ma zastosowania do tej usługi." + base.DisputeNo = "Operator oświadcza, że nie uczestniczy w postępowaniach dotyczących pozasądowego rozwiązywania sporów konsumenckich, chyba że obowiązek taki wynika z prawa." + base.DisputeYes = "Operator oświadcza, że uczestniczy w postępowaniach dotyczących rozwiązywania sporów przed wskazanym poniżej podmiotem." + base.PrivacyTitle, base.PrivacyLead = "Informacje o prywatności", "Informacje o działaniu usługi i przetwarzaniu technicznych danych połączenia." + base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Administrator / operator", "Kontakt ds. ochrony danych", "Dostawca hostingu", "Adres dostawcy hostingu" + base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Logi serwera", "Okres przechowywania logów", "Odbiorcy / podmioty przetwarzające", "Transfery do państw trzecich", "Organ nadzorczy" + base.PrivacyTechTitle, base.PrivacyTechText = "Rozwiązania techniczne", "Sama aplikacja nie wymaga trackerów reklamowych ani zewnętrznych narzędzi analitycznych. W zależności od wdrożenia reverse proxy, hosting lub infrastruktura otaczająca mogą przetwarzać dane połączeń i logi. Operator musi dostosować informacje na tej stronie do rzeczywistego działania systemu." + base.AccessibilityTitle, base.AccessibilityLead = "Dostępność", "Informacje i kontakt w sprawie dostępności tej usługi." + base.AccessibilityContact, base.AccessibilityStatus = "Kontakt ds. dostępności", "Stan dostępności" + base.AccessibilityTechTitle, base.AccessibilityTechText = "Podejście techniczne", "Aplikacja wykorzystuje semantyczny HTML, elementy obsługiwane klawiaturą oraz metadane językowe. Informacja ta nie stanowi certyfikacji. Operator nadal odpowiada za sprawdzenie konkretnego wdrożenia, własnych treści, identyfikacji wizualnej i integracji." + base.NoticeTitle, base.Notice = "Ważna informacja", "Te strony zawierają konfigurowalne informacje operatora i nie stanowią porady prawnej. Ich kompletność zależy od wdrożenia i właściwego prawa." + } + return base +} + +func normalizeLanguage(lang string) string { + lang = strings.ToLower(strings.TrimSpace(lang)) + if i := strings.IndexByte(lang, '-'); i >= 0 { + lang = lang[:i] + } + return lang +} + +func legalLabel(lang, kind string) string { + copy := legalLabels(lang) + switch kind { + case "privacy": + return copy.PrivacyLabel + case "accessibility": + return copy.AccessibilityLabel + default: + return copy.LegalLabel + } +} + +func (s *Server) operatorPage(lang, kind string) legalPage { + c := legalLabels(lang) + nonEmpty := func(label, value string) []legalItem { + if strings.TrimSpace(value) == "" { + return nil + } + return []legalItem{{Label: label, Value: value}} + } + appendItem := func(items []legalItem, label, value, link string) []legalItem { + if strings.TrimSpace(value) == "" { + return items + } + return append(items, legalItem{Label: label, Value: value, URL: link}) + } + + switch kind { + case "privacy": + operator := []legalItem{} + operator = appendItem(operator, c.Controller, s.cfg.LegalName, "") + operator = appendItem(operator, c.Address, s.cfg.LegalAddress, "") + operator = appendItem(operator, c.Email, s.cfg.LegalEmail, mailto(s.cfg.LegalEmail)) + operator = appendItem(operator, c.DataProtection, s.cfg.DataProtectionContact, mailtoIfEmail(s.cfg.DataProtectionContact)) + hosting := []legalItem{} + hosting = appendItem(hosting, c.Hosting, s.cfg.HostingProvider, "") + hosting = appendItem(hosting, c.HostingAddress, s.cfg.HostingAddress, "") + processing := []legalItem{} + processing = appendItem(processing, c.Retention, s.cfg.LogRetention, "") + processing = appendItem(processing, c.Recipients, s.cfg.DataRecipients, "") + processing = appendItem(processing, c.Transfers, s.cfg.ThirdCountryTransfers, "") + processing = appendItem(processing, c.Authority, s.cfg.SupervisoryAuthorityName, s.cfg.SupervisoryAuthorityURL) + sections := []legalSection{{Title: c.Controller, Items: operator}, {Title: c.Hosting, Items: hosting}, {Title: c.PrivacyTechTitle, Paragraphs: []string{c.PrivacyTechText}, Items: processing}} + return legalPage{Kind: kind, Title: c.PrivacyTitle, Lead: c.PrivacyLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice} + case "accessibility": + items := []legalItem{} + items = appendItem(items, c.AccessibilityContact, s.cfg.AccessibilityContact, mailtoIfEmail(s.cfg.AccessibilityContact)) + items = appendItem(items, c.AccessibilityStatus, s.cfg.AccessibilityStatus, "") + sections := []legalSection{{Title: c.AccessibilityTitle, Items: items}, {Title: c.AccessibilityTechTitle, Paragraphs: []string{c.AccessibilityTechText}}} + return legalPage{Kind: kind, Title: c.AccessibilityTitle, Lead: c.AccessibilityLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice} + default: + operator := []legalItem{} + operator = appendItem(operator, c.Operator, s.cfg.LegalName, "") + operator = appendItem(operator, c.Address, s.cfg.LegalAddress, "") + operator = appendItem(operator, c.Representative, s.cfg.LegalRepresentative, "") + operator = appendItem(operator, c.Email, s.cfg.LegalEmail, mailto(s.cfg.LegalEmail)) + operator = appendItem(operator, c.Phone, s.cfg.LegalPhone, tel(s.cfg.LegalPhone)) + operator = appendItem(operator, c.Register, s.cfg.LegalRegister, "") + operator = appendItem(operator, c.RegisterNo, s.cfg.LegalRegisterNumber, "") + operator = appendItem(operator, c.VAT, s.cfg.LegalVATID, "") + sections := []legalSection{{Title: c.Operator, Items: operator}} + if strings.TrimSpace(s.cfg.EditorialResponsibleName) != "" || strings.TrimSpace(s.cfg.EditorialResponsibleAddress) != "" { + items := []legalItem{} + items = appendItem(items, c.Editorial, s.cfg.EditorialResponsibleName, "") + items = appendItem(items, c.EditorialAddress, s.cfg.EditorialResponsibleAddress, "") + sections = append(sections, legalSection{Title: c.Editorial, Items: items}) + } + disputeText := c.DisputeNA + if s.cfg.ConsumerDisputeStatus == "not_participating" { + disputeText = c.DisputeNo + } else if s.cfg.ConsumerDisputeStatus == "participating" { + disputeText = c.DisputeYes + } + disputeItems := nonEmpty(c.DisputeTitle, s.cfg.ConsumerDisputeBody) + if len(disputeItems) > 0 && strings.TrimSpace(s.cfg.ConsumerDisputeURL) != "" { + disputeItems[0].URL = s.cfg.ConsumerDisputeURL + } + sections = append(sections, legalSection{Title: c.DisputeTitle, Paragraphs: []string{disputeText}, Items: disputeItems}) + return legalPage{Kind: "legal", Title: c.LegalTitle, Lead: c.LegalLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice} + } +} + +func (s *Server) handleOperatorPage(kind string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + lang := s.language(r) + data := pageData{ + Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Text: i18n.Get(lang).Text, + Languages: i18nLanguages(), LegalPage: s.operatorPage(lang, kind), License: s.licenses.Status(), + WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel), + } + s.renderHTML(w, "operator.html", data) + } +} + +func i18nLanguages() []i18n.LanguageOption { return i18n.Languages() } + +func mailto(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return "mailto:" + value +} +func mailtoIfEmail(value string) string { + value = strings.TrimSpace(value) + if strings.Contains(value, "@") && !strings.ContainsAny(value, " \t\r\n") { + return "mailto:" + value + } + return "" +} +func tel(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + replacer := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "", "/", "") + return "tel:" + replacer.Replace(value) +} diff --git a/internal/app/licensing.go b/internal/app/licensing.go index 847f7c9..0ff94be 100644 --- a/internal/app/licensing.go +++ b/internal/app/licensing.go @@ -9,7 +9,7 @@ import ( const ( ProductID = "ai-disclosure-standard" - ProductVersion = "2.0.0" + ProductVersion = "2.0.2" FeatureCustomText = "custom_text" FeatureCustomBadge = "custom_badge" FeatureWhiteLabel = "white_label" diff --git a/internal/app/server.go b/internal/app/server.go index 380cc8e..3288a7b 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -12,7 +12,9 @@ import ( "io" "io/fs" "log/slog" + "net" "net/http" + "net/netip" "net/url" "runtime/debug" "sort" @@ -85,8 +87,11 @@ type pageData struct { Assessment assessmentView RegulatoryFacts []fact BulkAPI bool + BulkMaxItems int + BulkRequireAPIKey bool ExportBundle bool WhiteLabel bool + LegalPage legalPage } type clientConfig struct { @@ -96,8 +101,18 @@ type clientConfig struct { Capabilities map[string]bool `json:"capabilities"` } +type bulkClientConfig struct { + Endpoint string `json:"endpoint"` + MaxItems int `json:"maxItems"` + RequireAPIKey bool `json:"requireAPIKey"` + Texts map[string]string `json:"texts"` +} + func New(ctx context.Context, cfg Config, logger *slog.Logger) (http.Handler, error) { - tmpl, err := template.New("root").ParseFS(webassets.Files, "templates/*.html") + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + tmpl, err := template.New("root").Funcs(template.FuncMap{"legalLabel": legalLabel}).ParseFS(webassets.Files, "templates/*.html") if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } @@ -121,6 +136,9 @@ func (s *Server) routes() { staticFS, _ := fs.Sub(webassets.Files, "static") fileServer := http.FileServer(http.FS(staticFS)) + // Static assets are also required by the dedicated bulk workspace. + s.mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(fileServer))) + // Operational and machine-readable endpoints are available in every mode. s.mux.HandleFunc("POST /v1/validate", s.handleValidate) s.mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities) @@ -129,16 +147,24 @@ 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) + s.mux.HandleFunc("GET /metrics", s.handleMetrics) // Licensed batch processing is intentionally exposed in API and bulk modes. s.mux.HandleFunc("POST /v1/bulk/declarations", s.handleBulkDeclarations) + if s.cfg.ServiceMode != "api" { + s.mux.HandleFunc("GET /legal", s.handleOperatorPage("legal")) + s.mux.HandleFunc("GET /imprint", s.handleOperatorPage("legal")) + s.mux.HandleFunc("GET /privacy", s.handleOperatorPage("privacy")) + s.mux.HandleFunc("GET /accessibility", s.handleOperatorPage("accessibility")) + } + if s.cfg.ServiceMode == "bulk" { + s.mux.HandleFunc("GET /", s.handleBulkWorkspace) + s.mux.HandleFunc("GET /bulk", s.handleBulkWorkspace) return } - s.mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(fileServer))) s.mux.HandleFunc("GET /badge/{file}", s.handlePresetBadge) s.mux.HandleFunc("GET /v1/badge.svg", s.handleBadge) s.mux.HandleFunc("GET /declaration", s.handleDeclaration) @@ -150,11 +176,61 @@ func (s *Server) routes() { } s.mux.HandleFunc("GET /", s.handleIndex) + s.mux.HandleFunc("GET /bulk", s.handleBulkWorkspace) s.mux.HandleFunc("GET /product", s.handleMarketing) s.mux.HandleFunc("GET /background", s.handleBackground) s.mux.HandleFunc("GET /install", s.handleMarketingAlias) } +func (s *Server) effectiveBulkMaxItems() int { + limit := s.cfg.BulkMaxItems + if licensedLimit, ok := s.licenses.Limit("bulk_items"); ok && licensedLimit > 0 && int64(limit) > licensedLimit { + limit = int(licensedLimit) + } + return limit +} + +func (s *Server) handleBulkWorkspace(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/bulk" { + http.NotFound(w, r) + return + } + lang := s.language(r) + locale := i18n.Get(lang) + cfg := bulkClientConfig{ + Endpoint: "/v1/bulk/declarations", + MaxItems: s.effectiveBulkMaxItems(), + RequireAPIKey: s.cfg.BulkRequireAPIKey, + Texts: map[string]string{ + "error_no_items": locale.Text["bulk_error_no_items"], + "error_too_many_items": locale.Text["bulk_error_too_many_items"], + "error_api_key": locale.Text["bulk_error_api_key"], + "status_running": locale.Text["bulk_status_running"], + "status_done": locale.Text["bulk_status_done"], + "status_error": locale.Text["bulk_status_error"], + "summary_processed": locale.Text["bulk_summary_processed"], + "summary_successful": locale.Text["bulk_summary_successful"], + "summary_failed": locale.Text["bulk_summary_failed"], + "result_ok": locale.Text["bulk_result_ok"], + "result_error": locale.Text["bulk_result_error"], + "link_declaration": locale.Text["bulk_link_declaration"], + "link_manifest": locale.Text["bulk_link_manifest"], + "link_badge": locale.Text["bulk_link_badge"], + }, + } + appJSON, _ := json.Marshal(cfg) + data := pageData{ + Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Text: locale.Text, + Languages: i18n.Languages(), Extents: orderedOptions(locale.Extents, []string{"none", "assisted", "partial", "mostly", "full"}), + Reviews: orderedOptions(locale.Reviews, []string{"none", "basic", "editorial", "expert"}), + Assurances: orderedOptions(locale.Assurances, []string{"selfDeclared", "technicallyRecorded", "signed", "verified"}), + AppConfig: template.JS(appJSON), License: s.licenses.Status(), BulkAPI: s.licenses.Has(FeatureBulkAPI), + BulkMaxItems: s.effectiveBulkMaxItems(), BulkRequireAPIKey: s.cfg.BulkRequireAPIKey, + WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel), + } + s.renderHTML(w, "bulk.html", data) +} + func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -495,6 +571,25 @@ func (s *Server) handleContext(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(strings.ReplaceAll(jsonLDContext, "__BASE_URL__", s.cfg.BaseURL))) } +func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { + if !s.cfg.MetricsEnabled { + http.NotFound(w, r) + return + } + expected := strings.TrimSpace(s.cfg.MetricsToken) + auth := strings.TrimSpace(r.Header.Get("Authorization")) + provided := "" + if strings.HasPrefix(strings.ToLower(auth), "bearer ") { + provided = strings.TrimSpace(auth[len("Bearer "):]) + } + if len(provided) != len(expected) || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { + w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + s.metrics.serveHTTP(w, r) +} + func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") @@ -552,6 +647,9 @@ func (s *Server) middleware(next http.Handler) http.Handler { 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") + if s.cfg.EnableHSTS { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/schema/") || strings.HasPrefix(r.URL.Path, "/context/") { if s.cfg.APIAllowedOrigin != "" { w.Header().Set("Access-Control-Allow-Origin", s.cfg.APIAllowedOrigin) @@ -564,7 +662,7 @@ func (s *Server) middleware(next http.Handler) http.Handler { } } rw := &responseWriter{ResponseWriter: w, status: http.StatusOK} - if s.cfg.RequireLicense && r.URL.Path != "/healthz" && r.URL.Path != "/readyz" && r.URL.Path != "/metrics" && r.URL.Path != "/v1/capabilities" && !s.licenses.Status().Licensed { + if s.cfg.RequireLicense && !licenseExemptPath(r.URL.Path) && !s.licenses.Status().Licensed { s.problem(rw, http.StatusServiceUnavailable, "license_required", "This deployment requires a valid runtime license.") return } @@ -574,12 +672,24 @@ 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)) + args := []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 { + args = append(args, "remote", clientIP(r, s.cfg.TrustProxy, s.cfg.TrustedProxies)) + } + s.logger.Info("request", args...) }() next.ServeHTTP(rw, r) }) } +func licenseExemptPath(path string) bool { + switch path { + case "/healthz", "/readyz", "/metrics", "/v1/capabilities", "/legal", "/imprint", "/privacy", "/accessibility": + return true + } + return strings.HasPrefix(path, "/static/") +} + func (s *Server) bulkAuthorized(r *http.Request) bool { if !s.cfg.BulkRequireAPIKey { return true @@ -1097,14 +1207,43 @@ func randomID() string { } return hex.EncodeToString(b) } -func clientIP(r *http.Request, trustProxy bool) string { - if trustProxy { +func clientIP(r *http.Request, trustProxy bool, trusted []netip.Prefix) string { + direct := remoteIP(r.RemoteAddr) + if trustProxy && direct.IsValid() && addressInPrefixes(direct, trusted) { if x := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); x != "" { - return x + if forwarded, err := netip.ParseAddr(x); err == nil { + return forwarded.String() + } + } + if x := strings.TrimSpace(r.Header.Get("X-Real-IP")); x != "" { + if forwarded, err := netip.ParseAddr(x); err == nil { + return forwarded.String() + } } } + if direct.IsValid() { + return direct.String() + } return r.RemoteAddr } + +func remoteIP(remote string) netip.Addr { + host, _, err := net.SplitHostPort(remote) + if err != nil { + host = remote + } + addr, _ := netip.ParseAddr(strings.Trim(host, "[]")) + return addr +} + +func addressInPrefixes(addr netip.Addr, prefixes []netip.Prefix) bool { + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} func ensureEOF(dec *json.Decoder) error { var extra any err := dec.Decode(&extra) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index b9f328f..0a6f3ee 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -352,8 +352,11 @@ func TestBulkModeRequiresLicensedCapabilityForReadiness(t *testing.T) { r = httptest.NewRequest(http.MethodGet, "/", nil) w = httptest.NewRecorder() h.ServeHTTP(w, r) - if w.Code != http.StatusNotFound { - t.Fatalf("bulk mode should not expose UI, got %d", w.Code) + if w.Code != http.StatusOK { + t.Fatalf("bulk workspace status=%d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "Bulk Workspace") || !strings.Contains(w.Body.String(), "bulk_api") { + t.Fatalf("bulk workspace missing expected content: %s", w.Body.String()) } } diff --git a/internal/i18n/bulk.go b/internal/i18n/bulk.go new file mode 100644 index 0000000..27fce92 --- /dev/null +++ b/internal/i18n/bulk.go @@ -0,0 +1,58 @@ +package i18n + +func applyBulkTranslations() { + texts := map[string]map[string]string{ + "de": { + "bulk_meta_description": "Professionelle Stapelverarbeitung für KI-Nutzungserklärungen.", "bulk_nav_workspace": "Arbeitsbereich", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Ergebnisse", + "bulk_eyebrow": "Bulk Workspace", "bulk_title": "Viele Inhalte. Eine konsistente Erklärung.", "bulk_intro": "Erzeuge strukturierte KI-Nutzungserklärungen für ganze Artikelbestände. Ein gemeinsames Profil kann auf viele URLs angewendet oder als individuelles JSON verarbeitet werden.", + "bulk_status": "Betriebsstatus", "bulk_license": "Bulk-Lizenz", "bulk_active": "Aktiv", "bulk_inactive": "Nicht verfügbar", "bulk_limit": "Max. Elemente", "bulk_auth": "API-Schutz", "bulk_api_key": "API-Key erforderlich", "bulk_no_api_key": "Nicht erforderlich", + "bulk_inactive_title": "Bulk-Verarbeitung ist nicht freigeschaltet.", "bulk_inactive_help": "Die Oberfläche bleibt zur Konfiguration sichtbar; die Verarbeitung benötigt die lizenzierte Capability bulk_api.", + "bulk_workspace_eyebrow": "Massenverarbeitung", "bulk_workspace_title": "Ein Profil auf viele Inhalte anwenden", "bulk_workspace_intro": "Füge Artikel oder Seiten zeilenweise ein und definiere einmal die gemeinsame KI-Nutzung. Abweichende Datensätze können anschließend über den erweiterten JSON-Modus verarbeitet werden.", + "bulk_api_key_label": "Bulk API-Key", "bulk_api_key_placeholder": "API-Key für diese Instanz", "bulk_api_key_help": "Der Schlüssel wird nur im Sitzungsspeicher dieses Browser-Tabs gehalten und nicht in die erzeugten Ergebnisse geschrieben.", + "bulk_items_label": "Artikel / Seiten", "bulk_items_placeholder": "article-1001 | https://example.org/artikel/1001\narticle-1002 | https://example.org/artikel/1002", "bulk_items_help": "Eine Zeile pro Inhalt. Unterstützt „ID | URL“, „IDURL“ oder nur eine URL. Die ID wird andernfalls automatisch erzeugt.", "bulk_sample": "Beispieldaten einsetzen", + "bulk_profile_title": "Gemeinsames Deklarationsprofil", "bulk_profile_intro": "Diese Angaben werden auf alle Einträge der Liste angewendet.", "bulk_cover_image": "Titelbild", "bulk_images": "Weitere Bilder", "bulk_research": "Recherche", "bulk_translation": "Übersetzung", + "bulk_article50_title": "Regulatorischer Kontext / Artikel 50", "bulk_run": "Stapelverarbeitung starten", + "bulk_advanced_eyebrow": "Erweitert", "bulk_advanced_title": "Individuelle JSON-Stapel", "bulk_advanced_intro": "Für heterogene Inhalte kann jeder Datensatz seine eigenen Parameter erhalten. Das Format entspricht direkt POST /v1/bulk/declarations.", "bulk_json_label": "Bulk-Request (JSON)", "bulk_json_sample": "Aktuelles Profil als JSON übernehmen", "bulk_json_run": "JSON verarbeiten", + "bulk_results_eyebrow": "Ausgabe", "bulk_results_title": "Verarbeitungsergebnisse", "bulk_copy_json": "JSON kopieren", "bulk_download_json": "JSON herunterladen", "bulk_download_csv": "CSV herunterladen", "bulk_col_id": "ID", "bulk_col_status": "Status", "bulk_col_assessment": "Artikel-50-Einordnung", "bulk_col_links": "Ausgaben", "bulk_raw_result": "Rohdaten anzeigen", "bulk_footer_note": "Bulk-Verarbeitung · keine Rechtsberatung", + "bulk_error_no_items": "Füge mindestens einen Inhalt hinzu.", "bulk_error_too_many_items": "Die zulässige Anzahl von Elementen wurde überschritten.", "bulk_error_api_key": "Für die Bulk-Verarbeitung ist ein API-Key erforderlich.", "bulk_status_running": "Verarbeitung läuft …", "bulk_status_done": "Verarbeitung abgeschlossen.", "bulk_status_error": "Fehler", "bulk_summary_processed": "Verarbeitet", "bulk_summary_successful": "Erfolgreich", "bulk_summary_failed": "Fehlgeschlagen", "bulk_result_ok": "OK", "bulk_result_error": "Fehler", "bulk_link_declaration": "Erklärung", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge", + }, + "en": { + "bulk_meta_description": "Professional batch processing for AI usage declarations.", "bulk_nav_workspace": "Workspace", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Results", + "bulk_eyebrow": "Bulk Workspace", "bulk_title": "Many items. One consistent disclosure.", "bulk_intro": "Generate structured AI usage declarations for entire content inventories. Apply one shared profile to many URLs or process individual records as JSON.", + "bulk_status": "Service status", "bulk_license": "Bulk licence", "bulk_active": "Active", "bulk_inactive": "Unavailable", "bulk_limit": "Max. items", "bulk_auth": "API protection", "bulk_api_key": "API key required", "bulk_no_api_key": "Not required", + "bulk_inactive_title": "Bulk processing is not enabled.", "bulk_inactive_help": "The workspace remains visible for configuration; processing requires the licensed bulk_api capability.", + "bulk_workspace_eyebrow": "Batch processing", "bulk_workspace_title": "Apply one profile to many items", "bulk_workspace_intro": "Paste articles or pages line by line and define their shared AI usage once. Records that differ can be processed through the advanced JSON mode.", + "bulk_api_key_label": "Bulk API key", "bulk_api_key_placeholder": "API key for this instance", "bulk_api_key_help": "The key is kept only in this browser tab's session storage and is never written into generated results.", + "bulk_items_label": "Articles / pages", "bulk_items_placeholder": "article-1001 | https://example.org/articles/1001\narticle-1002 | https://example.org/articles/1002", "bulk_items_help": "One item per line. Supports “ID | URL”, “IDURL” or a URL alone. An ID is generated automatically when omitted.", "bulk_sample": "Insert sample data", + "bulk_profile_title": "Shared declaration profile", "bulk_profile_intro": "These settings are applied to every item in the list.", "bulk_cover_image": "Cover image", "bulk_images": "Other images", "bulk_research": "Research", "bulk_translation": "Translation", + "bulk_article50_title": "Regulatory context / Article 50", "bulk_run": "Start batch processing", + "bulk_advanced_eyebrow": "Advanced", "bulk_advanced_title": "Individual JSON batches", "bulk_advanced_intro": "For heterogeneous content, every item can carry its own parameters. The format maps directly to POST /v1/bulk/declarations.", "bulk_json_label": "Bulk request (JSON)", "bulk_json_sample": "Use current profile as JSON", "bulk_json_run": "Process JSON", + "bulk_results_eyebrow": "Output", "bulk_results_title": "Processing results", "bulk_copy_json": "Copy JSON", "bulk_download_json": "Download JSON", "bulk_download_csv": "Download CSV", "bulk_col_id": "ID", "bulk_col_status": "Status", "bulk_col_assessment": "Article 50 assessment", "bulk_col_links": "Outputs", "bulk_raw_result": "Show raw data", "bulk_footer_note": "Bulk processing · not legal advice", + "bulk_error_no_items": "Add at least one item.", "bulk_error_too_many_items": "The permitted item count has been exceeded.", "bulk_error_api_key": "A bulk API key is required.", "bulk_status_running": "Processing …", "bulk_status_done": "Processing completed.", "bulk_status_error": "Error", "bulk_summary_processed": "Processed", "bulk_summary_successful": "Successful", "bulk_summary_failed": "Failed", "bulk_result_ok": "OK", "bulk_result_error": "Error", "bulk_link_declaration": "Declaration", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge", + }, + "fr": { + "bulk_title": "Plusieurs contenus. Une déclaration cohérente.", "bulk_intro": "Générez des déclarations structurées d’utilisation de l’IA pour des ensembles complets de contenus.", "bulk_nav_workspace": "Espace de travail", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Résultats", "bulk_eyebrow": "Traitement en lot", "bulk_workspace_eyebrow": "Traitement en lot", "bulk_workspace_title": "Appliquer un profil à plusieurs contenus", "bulk_workspace_intro": "Ajoutez les articles ou pages ligne par ligne puis définissez une seule fois leur utilisation commune de l’IA.", "bulk_items_label": "Articles / pages", "bulk_items_help": "Un contenu par ligne : « ID | URL », « IDURL » ou URL seule.", "bulk_sample": "Insérer un exemple", "bulk_profile_title": "Profil de déclaration commun", "bulk_profile_intro": "Ces paramètres s’appliquent à tous les éléments.", "bulk_run": "Lancer le traitement", "bulk_advanced_title": "Lots JSON individuels", "bulk_advanced_intro": "Chaque élément peut utiliser ses propres paramètres.", "bulk_json_label": "Requête bulk (JSON)", "bulk_json_sample": "Reprendre le profil en JSON", "bulk_json_run": "Traiter le JSON", "bulk_results_title": "Résultats du traitement", "bulk_copy_json": "Copier le JSON", "bulk_download_json": "Télécharger JSON", "bulk_download_csv": "Télécharger CSV", "bulk_col_id": "ID", "bulk_col_status": "Statut", "bulk_col_assessment": "Évaluation article 50", "bulk_col_links": "Sorties", "bulk_raw_result": "Afficher les données brutes", "bulk_api_key_label": "Clé API bulk", "bulk_api_key_help": "La clé reste uniquement dans le stockage de session de cet onglet.", "bulk_license": "Licence bulk", "bulk_active": "Active", "bulk_inactive": "Indisponible", "bulk_limit": "Éléments max.", "bulk_auth": "Protection API", "bulk_api_key": "Clé requise", "bulk_no_api_key": "Non requise", "bulk_cover_image": "Image de couverture", "bulk_images": "Autres images", "bulk_research": "Recherche", "bulk_translation": "Traduction", "bulk_article50_title": "Contexte réglementaire / article 50", "bulk_result_ok": "OK", "bulk_result_error": "Erreur", "bulk_link_declaration": "Déclaration", "bulk_link_manifest": "Manifeste", "bulk_link_badge": "Badge", + }, + "es": {"bulk_title": "Muchos contenidos. Una declaración coherente.", "bulk_intro": "Genera declaraciones estructuradas de uso de IA para inventarios completos de contenido.", "bulk_nav_workspace": "Espacio de trabajo", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultados", "bulk_workspace_title": "Aplicar un perfil a muchos contenidos", "bulk_items_label": "Artículos / páginas", "bulk_items_help": "Un contenido por línea: «ID | URL», «IDURL» o solo URL.", "bulk_sample": "Insertar ejemplo", "bulk_profile_title": "Perfil común de declaración", "bulk_run": "Iniciar procesamiento", "bulk_advanced_title": "Lotes JSON individuales", "bulk_json_run": "Procesar JSON", "bulk_results_title": "Resultados", "bulk_copy_json": "Copiar JSON", "bulk_download_json": "Descargar JSON", "bulk_download_csv": "Descargar CSV", "bulk_api_key_label": "Clave API bulk", "bulk_license": "Licencia bulk", "bulk_active": "Activa", "bulk_inactive": "No disponible", "bulk_limit": "Máx. elementos", "bulk_auth": "Protección API", "bulk_api_key": "Clave requerida", "bulk_no_api_key": "No requerida", "bulk_cover_image": "Imagen de portada", "bulk_images": "Otras imágenes", "bulk_research": "Investigación", "bulk_translation": "Traducción", "bulk_article50_title": "Contexto regulatorio / artículo 50", "bulk_result_ok": "OK", "bulk_result_error": "Error", "bulk_link_declaration": "Declaración", "bulk_link_manifest": "Manifiesto", "bulk_link_badge": "Badge"}, + "it": {"bulk_title": "Molti contenuti. Una dichiarazione coerente.", "bulk_intro": "Genera dichiarazioni strutturate sull’uso dell’IA per interi archivi di contenuti.", "bulk_nav_workspace": "Area di lavoro", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Risultati", "bulk_workspace_title": "Applicare un profilo a molti contenuti", "bulk_items_label": "Articoli / pagine", "bulk_items_help": "Un contenuto per riga: «ID | URL», «IDURL» o solo URL.", "bulk_sample": "Inserisci esempio", "bulk_profile_title": "Profilo comune", "bulk_run": "Avvia elaborazione", "bulk_advanced_title": "Batch JSON individuali", "bulk_json_run": "Elabora JSON", "bulk_results_title": "Risultati", "bulk_copy_json": "Copia JSON", "bulk_download_json": "Scarica JSON", "bulk_download_csv": "Scarica CSV", "bulk_api_key_label": "API key bulk", "bulk_license": "Licenza bulk", "bulk_active": "Attiva", "bulk_inactive": "Non disponibile", "bulk_limit": "Elementi max.", "bulk_auth": "Protezione API", "bulk_api_key": "Chiave richiesta", "bulk_no_api_key": "Non richiesta", "bulk_cover_image": "Immagine di copertina", "bulk_images": "Altre immagini", "bulk_research": "Ricerca", "bulk_translation": "Traduzione", "bulk_article50_title": "Contesto normativo / articolo 50", "bulk_result_ok": "OK", "bulk_result_error": "Errore", "bulk_link_declaration": "Dichiarazione", "bulk_link_manifest": "Manifesto", "bulk_link_badge": "Badge"}, + "nl": {"bulk_title": "Veel inhoud. Eén consistente verklaring.", "bulk_intro": "Genereer gestructureerde AI-gebruiksverklaringen voor complete contentcollecties.", "bulk_nav_workspace": "Werkruimte", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultaten", "bulk_workspace_title": "Eén profiel op veel inhoud toepassen", "bulk_items_label": "Artikelen / pagina's", "bulk_items_help": "Eén item per regel: ‘ID | URL’, ‘IDURL’ of alleen een URL.", "bulk_sample": "Voorbeeld invoegen", "bulk_profile_title": "Gedeeld declaratieprofiel", "bulk_run": "Batchverwerking starten", "bulk_advanced_title": "Individuele JSON-batches", "bulk_json_run": "JSON verwerken", "bulk_results_title": "Resultaten", "bulk_copy_json": "JSON kopiëren", "bulk_download_json": "JSON downloaden", "bulk_download_csv": "CSV downloaden", "bulk_api_key_label": "Bulk API-key", "bulk_license": "Bulk-licentie", "bulk_active": "Actief", "bulk_inactive": "Niet beschikbaar", "bulk_limit": "Max. items", "bulk_auth": "API-beveiliging", "bulk_api_key": "Key vereist", "bulk_no_api_key": "Niet vereist", "bulk_cover_image": "Omslagafbeelding", "bulk_images": "Andere afbeeldingen", "bulk_research": "Onderzoek", "bulk_translation": "Vertaling", "bulk_article50_title": "Regelgevingscontext / artikel 50", "bulk_result_ok": "OK", "bulk_result_error": "Fout", "bulk_link_declaration": "Verklaring", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge"}, + "pt": {"bulk_title": "Muitos conteúdos. Uma declaração consistente.", "bulk_intro": "Gere declarações estruturadas de uso de IA para conjuntos completos de conteúdos.", "bulk_nav_workspace": "Área de trabalho", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultados", "bulk_workspace_title": "Aplicar um perfil a muitos conteúdos", "bulk_items_label": "Artigos / páginas", "bulk_items_help": "Um conteúdo por linha: «ID | URL», «IDURL» ou apenas URL.", "bulk_sample": "Inserir exemplo", "bulk_profile_title": "Perfil de declaração comum", "bulk_run": "Iniciar processamento", "bulk_advanced_title": "Lotes JSON individuais", "bulk_json_run": "Processar JSON", "bulk_results_title": "Resultados", "bulk_copy_json": "Copiar JSON", "bulk_download_json": "Transferir JSON", "bulk_download_csv": "Transferir CSV", "bulk_api_key_label": "Chave API bulk", "bulk_license": "Licença bulk", "bulk_active": "Ativa", "bulk_inactive": "Indisponível", "bulk_limit": "Máx. elementos", "bulk_auth": "Proteção API", "bulk_api_key": "Chave necessária", "bulk_no_api_key": "Não necessária", "bulk_cover_image": "Imagem de capa", "bulk_images": "Outras imagens", "bulk_research": "Pesquisa", "bulk_translation": "Tradução", "bulk_article50_title": "Contexto regulamentar / artigo 50", "bulk_result_ok": "OK", "bulk_result_error": "Erro", "bulk_link_declaration": "Declaração", "bulk_link_manifest": "Manifesto", "bulk_link_badge": "Badge"}, + "pl": {"bulk_title": "Wiele treści. Jedna spójna deklaracja.", "bulk_intro": "Twórz ustrukturyzowane deklaracje użycia AI dla całych zbiorów treści.", "bulk_nav_workspace": "Obszar roboczy", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Wyniki", "bulk_workspace_title": "Zastosuj jeden profil do wielu treści", "bulk_items_label": "Artykuły / strony", "bulk_items_help": "Jeden element na linię: „ID | URL”, „IDURL” lub sam URL.", "bulk_sample": "Wstaw przykład", "bulk_profile_title": "Wspólny profil deklaracji", "bulk_run": "Uruchom przetwarzanie", "bulk_advanced_title": "Indywidualne partie JSON", "bulk_json_run": "Przetwórz JSON", "bulk_results_title": "Wyniki", "bulk_copy_json": "Kopiuj JSON", "bulk_download_json": "Pobierz JSON", "bulk_download_csv": "Pobierz CSV", "bulk_api_key_label": "Klucz API bulk", "bulk_license": "Licencja bulk", "bulk_active": "Aktywna", "bulk_inactive": "Niedostępna", "bulk_limit": "Maks. elementów", "bulk_auth": "Ochrona API", "bulk_api_key": "Klucz wymagany", "bulk_no_api_key": "Niewymagany", "bulk_cover_image": "Obraz okładkowy", "bulk_images": "Inne obrazy", "bulk_research": "Badania", "bulk_translation": "Tłumaczenie", "bulk_article50_title": "Kontekst regulacyjny / artykuł 50", "bulk_result_ok": "OK", "bulk_result_error": "Błąd", "bulk_link_declaration": "Deklaracja", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge"}, + } + + base := texts["en"] + for code := range catalogs { + l := catalogs[code] + for key, value := range base { + if l.Text[key] == "" { + l.Text[key] = value + } + } + if overrides, ok := texts[code]; ok { + for key, value := range overrides { + l.Text[key] = value + } + } + catalogs[code] = l + } +} diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go index 85536f5..48f325e 100644 --- a/internal/i18n/catalog.go +++ b/internal/i18n/catalog.go @@ -136,6 +136,7 @@ func init() { applyArticleTranslations() applyAssuranceTranslations() applyArticle50Translations() + applyBulkTranslations() } func applyArticleTranslations() { diff --git a/internal/marketing/content.go b/internal/marketing/content.go index 3cbe9b5..72525a3 100644 --- a/internal/marketing/content.go +++ b/internal/marketing/content.go @@ -126,13 +126,13 @@ func Build(lang, productName, baseURL, contactURL string) Page { # Adjust BASE_URL and PUBLIC_NAME in .env docker compose up -d --build curl -fsS http://localhost:8080/readyz`}, - {ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:2.0.0 . + {ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:2.0.2 . docker run -d --name ai-disclosure \ -p 8080:8080 \ -e BASE_URL=https://ai.example.org \ -e PUBLIC_NAME="AI Usage Disclosure" \ --read-only --tmpfs /tmp \ - ai-disclosure-standard:2.0.0`}, + ai-disclosure-standard:2.0.2`}, {ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, domain and TLS secret in deploy/kubernetes.yaml ersetzen kubectl apply -f deploy/kubernetes.yaml kubectl rollout status deployment/ai-disclosure diff --git a/openapi.yaml b/openapi.yaml index 330c186..dff6c92 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: AI Usage Disclosure API - version: 2.0.0 + version: 2.0.2 description: >- Stateless multilingual API for visible and machine-readable AI usage declarations, non-binding Article 50 decision support, validation and optional licensed bulk/export capabilities. diff --git a/web/static/bulk.js b/web/static/bulk.js new file mode 100644 index 0000000..7b2b4ea --- /dev/null +++ b/web/static/bulk.js @@ -0,0 +1,258 @@ +(() => { + const cfg = window.BULK_CONFIG; + if (!cfg) return; + const $ = id => document.getElementById(id); + const t = (key, fallback = key) => (cfg.texts && cfg.texts[key]) || fallback; + let lastResult = null; + + const apiKeyInput = $('bulk-api-key'); + if (apiKeyInput) { + apiKeyInput.value = sessionStorage.getItem('aiDisclosureBulkApiKey') || ''; + apiKeyInput.addEventListener('input', () => sessionStorage.setItem('aiDisclosureBulkApiKey', apiKeyInput.value)); + } + + function parseLines() { + const raw = ($('bulk-items')?.value || '').split(/\r?\n/).map(v => v.trim()).filter(Boolean); + return raw.map((line, index) => { + let id = ''; + let subject = ''; + if (line.includes('|')) { + const [left, ...right] = line.split('|'); + id = left.trim(); + subject = right.join('|').trim(); + } else if (line.includes('\t')) { + const [left, ...right] = line.split('\t'); + id = left.trim(); + subject = right.join('\t').trim(); + } else { + subject = line; + } + if (!id) id = `item-${index + 1}`; + return {id, subject}; + }); + } + + function updateCount() { + const count = parseLines().length; + const el = $('bulk-item-count'); + if (el) { + el.textContent = `${count} / ${cfg.maxItems}`; + el.classList.toggle('over-limit', count > cfg.maxItems); + } + } + + function normalizeReview(extentId, reviewId) { + const extent = $(extentId); + const review = $(reviewId); + if (!extent || !review) return; + if (extent.value === 'none') review.value = 'none'; + else if (review.value === 'none') review.value = 'editorial'; + } + + const componentPairs = [ + ['bulk-text-extent', 'bulk-text-review'], + ['bulk-cover-extent', 'bulk-cover-review'], + ['bulk-image-extent', 'bulk-image-review'], + ['bulk-research-extent', 'bulk-research-review'], + ['bulk-translation-extent', 'bulk-translation-review'], + ['bulk-code-extent', 'bulk-code-review'] + ]; + componentPairs.forEach(([extentId, reviewId]) => { + $(extentId)?.addEventListener('change', () => normalizeReview(extentId, reviewId)); + }); + + function commonParameters(subject) { + const params = { + mode: 'article', + lang: $('bulk-output-language').value, + assurance: $('bulk-assurance').value, + textExtent: $('bulk-text-extent').value, + textReview: $('bulk-text-review').value, + coverImageExtent: $('bulk-cover-extent').value, + coverImageReview: $('bulk-cover-review').value, + imageExtent: $('bulk-image-extent').value, + imageReview: $('bulk-image-review').value, + researchExtent: $('bulk-research-extent').value, + researchReview: $('bulk-research-review').value, + translationExtent: $('bulk-translation-extent').value, + translationReview: $('bulk-translation-review').value, + codeExtent: $('bulk-code-extent').value, + codeReview: $('bulk-code-review').value + }; + if (subject) params.subject = subject; + const bools = { + 'bulk-public-interest': 'publicInterestText', + 'bulk-deepfake': 'deepfake', + 'bulk-substantial-review': 'substantialHumanReview', + 'bulk-editorial-responsibility': 'editorialResponsibilityConfirmed', + 'bulk-first-exposure': 'firstExposureDisclosure', + 'bulk-accessibility': 'accessibilityConsidered' + }; + Object.entries(bools).forEach(([id, key]) => { if ($(id)?.checked) params[key] = 'true'; }); + const responsible = $('bulk-responsible')?.value.trim(); + const responsibleURL = $('bulk-responsible-url')?.value.trim(); + if (responsible) params.responsible = responsible; + if (responsibleURL) params.responsibleUrl = responsibleURL; + return params; + } + + function buildVisualRequest() { + const items = parseLines(); + if (!items.length) throw new Error(t('error_no_items', 'Add at least one item.')); + if (items.length > cfg.maxItems) throw new Error(t('error_too_many_items', 'The configured item limit has been exceeded.')); + return {items: items.map(item => ({id: item.id, parameters: commonParameters(item.subject)}))}; + } + + function headers() { + const h = {'Content-Type': 'application/json'}; + const key = apiKeyInput?.value.trim(); + if (cfg.requireAPIKey) { + if (!key) throw new Error(t('error_api_key', 'A bulk API key is required.')); + h.Authorization = `Bearer ${key}`; + } else if (key) { + h.Authorization = `Bearer ${key}`; + } + return h; + } + + async function run(request) { + const status = $('bulk-run-status'); + if (status) status.textContent = t('status_running', 'Processing…'); + try { + const response = await fetch(cfg.endpoint, {method: 'POST', headers: headers(), body: JSON.stringify(request)}); + const data = await response.json().catch(() => ({code: 'invalid_response', detail: response.statusText})); + if (!response.ok) throw new Error(data.detail || data.title || `${response.status} ${response.statusText}`); + lastResult = data; + renderResults(data); + if (status) status.textContent = t('status_done', 'Completed.'); + } catch (err) { + if (status) status.textContent = `${t('status_error', 'Error')}: ${err.message}`; + throw err; + } + } + + function renderResults(data) { + const section = $('results'); + const body = $('bulk-result-body'); + const raw = $('bulk-result-json'); + if (!section || !body || !raw) return; + section.hidden = false; + body.textContent = ''; + const items = Array.isArray(data.items) ? data.items : []; + const valid = items.filter(item => item.valid).length; + const failed = items.length - valid; + $('bulk-result-summary').textContent = `${t('summary_processed', 'Processed')}: ${items.length} · ${t('summary_successful', 'Successful')}: ${valid} · ${t('summary_failed', 'Failed')}: ${failed}`; + + items.forEach(item => { + const tr = document.createElement('tr'); + const id = document.createElement('td'); + id.textContent = item.id || '—'; + const status = document.createElement('td'); + const pill = document.createElement('span'); + pill.className = `bulk-result-pill ${item.valid ? 'ok' : 'error'}`; + pill.textContent = item.valid ? t('result_ok', 'OK') : t('result_error', 'Error'); + status.appendChild(pill); + if (!item.valid && item.error?.detail) { + const detail = document.createElement('small'); + detail.className = 'bulk-error-detail'; + detail.textContent = item.error.detail; + status.appendChild(detail); + } + const assessment = document.createElement('td'); + assessment.textContent = item.article50Assessment?.code || '—'; + const links = document.createElement('td'); + if (item.valid) { + const linkData = [ + [t('link_declaration', 'Declaration'), item.declarationUrl], + [t('link_manifest', 'Manifest'), item.manifestUrl], + [t('link_badge', 'Badge'), item.badgeUrl] + ]; + linkData.forEach(([label, href], index) => { + if (!href) return; + if (index) links.appendChild(document.createTextNode(' · ')); + const a = document.createElement('a'); + a.href = href; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = label; + links.appendChild(a); + }); + } else links.textContent = '—'; + tr.append(id, status, assessment, links); + body.appendChild(tr); + }); + raw.textContent = JSON.stringify(data, null, 2); + section.scrollIntoView({behavior: 'smooth', block: 'start'}); + } + + function download(filename, content, type) { + const blob = new Blob([content], {type}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + function csvEscape(value) { + const s = String(value ?? ''); + return /[",\n\r;]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s; + } + + function resultCSV() { + const rows = [['id', 'valid', 'assessment', 'declaration_url', 'manifest_url', 'badge_url', 'error']]; + (lastResult?.items || []).forEach(item => rows.push([ + item.id || '', item.valid ? 'true' : 'false', item.article50Assessment?.code || '', item.declarationUrl || '', item.manifestUrl || '', item.badgeUrl || '', item.error?.detail || '' + ])); + return rows.map(row => row.map(csvEscape).join(';')).join('\n'); + } + + $('bulk-items')?.addEventListener('input', updateCount); + $('bulk-sample-items')?.addEventListener('click', () => { + $('bulk-items').value = 'article-1001 | https://example.org/articles/1001\narticle-1002 | https://example.org/articles/1002\narticle-1003 | https://example.org/articles/1003'; + updateCount(); + }); + $('bulk-run')?.addEventListener('click', async () => { + try { await run(buildVisualRequest()); } catch (_) { /* status already rendered */ } + }); + $('bulk-json-sample')?.addEventListener('click', () => { + try { + if (!parseLines().length) { + $('bulk-items').value = 'article-1001 | https://example.org/articles/1001'; + updateCount(); + } + $('bulk-json').value = JSON.stringify(buildVisualRequest(), null, 2); + } catch (err) { + $('bulk-run-status').textContent = `${t('status_error', 'Error')}: ${err.message}`; + } + }); + $('bulk-json-run')?.addEventListener('click', async () => { + try { + const request = JSON.parse($('bulk-json').value); + await run(request); + } catch (err) { + $('bulk-run-status').textContent = `${t('status_error', 'Error')}: ${err.message}`; + } + }); + $('bulk-copy-json')?.addEventListener('click', async () => { + if (!lastResult) return; + await navigator.clipboard.writeText(JSON.stringify(lastResult, null, 2)); + }); + $('bulk-download-json')?.addEventListener('click', () => { + if (lastResult) download('ai-disclosure-bulk-results.json', JSON.stringify(lastResult, null, 2), 'application/json'); + }); + $('bulk-download-csv')?.addEventListener('click', () => { + if (lastResult) download('ai-disclosure-bulk-results.csv', resultCSV(), 'text/csv;charset=utf-8'); + }); + $('bulk-language')?.addEventListener('change', event => { + const url = new URL(window.location.href); + url.searchParams.set('lang', event.target.value); + window.location.href = url.toString(); + }); + + updateCount(); + componentPairs.forEach(([extentId, reviewId]) => normalizeReview(extentId, reviewId)); +})(); diff --git a/web/static/style.css b/web/static/style.css index f528d4b..37b44a9 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -70,3 +70,11 @@ gap: 0.45rem; } .assessment-findings li { line-height: 1.55; } + +/* Bulk workspace */ +.bulk-main{max-width:1260px}.bulk-header{max-width:1260px}.bulk-hero{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(320px,.75fr);gap:48px;align-items:end;padding:64px 0 72px}.bulk-hero h1{font-size:clamp(3rem,7vw,6.2rem)}.bulk-status-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.bulk-status-card{display:flex;flex-direction:column;gap:5px;padding:18px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.bulk-status-card span{font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);font-weight:800}.bulk-status-card strong{font-size:1rem}.bulk-license-warning{margin-bottom:24px;border:1px solid #ecd39e;background:#fff8e9}.bulk-license-warning p{margin:.35rem 0 0}.bulk-workspace,.bulk-advanced,.bulk-results{margin-bottom:26px}.bulk-section-heading{align-items:flex-start}.bulk-section-heading p{max-width:760px;color:var(--muted)}.bulk-auth-box{margin:24px 0;padding:18px;border:1px solid var(--line);border-radius:14px;background:#f8f6f1}.bulk-auth-box label{display:flex;flex-direction:column;gap:7px;font-weight:800}.bulk-two-column{display:grid;grid-template-columns:minmax(0,.85fr) minmax(480px,1.15fr);gap:24px;margin-top:28px}.bulk-source-panel,.bulk-profile-panel{padding:22px;border:1px solid var(--line);border-radius:14px;background:#fbfaf7}.bulk-source-panel label,.bulk-profile-panel label,.bulk-advanced label{display:flex;flex-direction:column;gap:7px;font-weight:800}.bulk-source-panel textarea{min-height:300px;font:500 .84rem/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.bulk-profile-panel h3{margin:0 0 .35rem;font-size:1.25rem}.bulk-profile-panel>p{margin:.2rem 0 18px;color:var(--muted)}.bulk-profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.bulk-component-row{grid-column:1/-1;display:grid;grid-template-columns:minmax(125px,.8fr) minmax(0,1fr) minmax(0,1fr);gap:10px;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.bulk-component-row strong{font-size:.9rem}.bulk-inline-actions{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-top:14px}.bulk-counter{margin-left:auto;color:var(--muted);font-weight:800;font-size:.86rem}.bulk-counter.over-limit{color:#a12c2c}.bulk-legal-details{margin-top:24px;border:1px solid var(--line);border-radius:14px;background:#fbfaf7}.bulk-legal-details>summary{cursor:pointer;padding:18px 20px;font-weight:850}.bulk-legal-details[open]>summary{border-bottom:1px solid var(--line)}.bulk-legal-grid{padding:18px 20px 0}.bulk-responsibility-grid{padding:14px 20px 20px}.bulk-primary-actions{display:flex;align-items:center;gap:16px;margin-top:24px}.bulk-primary-actions #bulk-run-status{color:var(--muted);font-weight:700}.bulk-json-editor{min-height:310px;font:500 .82rem/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.bulk-table-wrap{overflow:auto;border:1px solid var(--line);border-radius:14px;background:#fff}.bulk-result-table{width:100%;min-width:820px;border-collapse:collapse}.bulk-result-table th,.bulk-result-table td{padding:13px 15px;text-align:left;vertical-align:top;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.bulk-result-table th:last-child,.bulk-result-table td:last-child{border-right:0}.bulk-result-table tbody tr:last-child td{border-bottom:0}.bulk-result-table thead th{background:#f7f5f0;font-size:.74rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.bulk-result-pill{display:inline-flex;padding:4px 9px;border-radius:999px;font-size:.76rem;font-weight:850}.bulk-result-pill.ok{background:#edf7ef;color:#276436}.bulk-result-pill.error{background:#fff0f0;color:#9b2929}.bulk-error-detail{display:block;max-width:360px;margin-top:7px;color:#8c3030}.bulk-result-summary{margin:0 0 16px;font-weight:800}.bulk-raw-result{margin-top:18px}.bulk-raw-result summary{cursor:pointer;font-weight:800}.bulk-raw-result pre{overflow:auto;max-height:520px;padding:18px;border-radius:12px;background:#151515;color:#eee;font-size:.8rem}.bulk-results a{font-weight:750}.bulk-page footer{max-width:1260px} +@media(max-width:900px){.bulk-hero{grid-template-columns:1fr}.bulk-two-column{grid-template-columns:1fr}.bulk-status-grid{grid-template-columns:1fr 1fr 1fr}}@media(max-width:700px){.bulk-status-grid{grid-template-columns:1fr}.bulk-profile-grid{grid-template-columns:1fr}.bulk-component-row{grid-template-columns:1fr}.bulk-counter{margin-left:0}.bulk-primary-actions{align-items:flex-start;flex-direction:column}.bulk-hero{padding-top:42px}} +button:disabled,.button:disabled{cursor:not-allowed;opacity:.45;transform:none;box-shadow:none} + +/* Operator, privacy and accessibility pages */ +.operator-main{max-width:1040px;margin:0 auto;padding:64px 24px 80px}.operator-hero{max-width:760px;margin-bottom:36px}.operator-hero h1{font-size:clamp(2.2rem,5vw,4.7rem);line-height:.98;letter-spacing:-.045em;margin:.12em 0 .35em;overflow-wrap:anywhere}.operator-grid{display:grid;gap:18px}.operator-card{background:var(--surface,#fff);border:1px solid var(--line,#dedede);border-radius:20px;padding:28px}.operator-card h2{margin:0 0 18px;font-size:1.2rem}.operator-card p{max-width:78ch;line-height:1.65}.operator-facts{display:grid;margin:0;border:1px solid var(--line,#dedede);border-radius:14px;overflow:hidden}.operator-facts>div{display:grid;grid-template-columns:minmax(180px,32%) 1fr;border-bottom:1px solid var(--line,#dedede)}.operator-facts>div:last-child{border-bottom:0}.operator-facts dt,.operator-facts dd{padding:13px 15px;margin:0;overflow-wrap:anywhere}.operator-facts dt{font-weight:650;background:rgba(127,127,127,.055);border-right:1px solid var(--line,#dedede)}.operator-facts dd{white-space:pre-line}.operator-notice{margin-top:20px;padding:20px 22px;border:1px solid var(--line,#dedede);border-radius:16px;background:rgba(127,127,127,.045)}.operator-notice p{margin:.45rem 0 0;max-width:85ch;line-height:1.6}@media(max-width:680px){.operator-main{padding:42px 16px 64px}.operator-card{padding:20px}.operator-facts>div{grid-template-columns:1fr}.operator-facts dt{border-right:0;border-bottom:1px solid var(--line,#dedede)}} diff --git a/web/templates/background.html b/web/templates/background.html index 11f5823..f537086 100644 --- a/web/templates/background.html +++ b/web/templates/background.html @@ -158,7 +158,7 @@ -
{{if not .WhiteLabel}}{{.Background.Footer}}{{end}}App 2.0.0 · Schema 1.2 · {{.License.Edition}}
+ diff --git a/web/templates/bulk.html b/web/templates/bulk.html new file mode 100644 index 0000000..029942c --- /dev/null +++ b/web/templates/bulk.html @@ -0,0 +1,163 @@ +{{define "bulk.html"}} + + + + + + {{.Name}} · {{index .Text "bulk_title"}} + + + + + + + +
+
+
+

{{index .Text "bulk_eyebrow"}}

+

{{index .Text "bulk_title"}}

+

{{index .Text "bulk_intro"}}

+
+
+
{{index .Text "bulk_license"}}{{if .BulkAPI}}{{index .Text "bulk_active"}}{{else}}{{index .Text "bulk_inactive"}}{{end}}
+
{{index .Text "bulk_limit"}}{{.BulkMaxItems}}
+
{{index .Text "bulk_auth"}}{{if .BulkRequireAPIKey}}{{index .Text "bulk_api_key"}}{{else}}{{index .Text "bulk_no_api_key"}}{{end}}
+
+
+ + {{if not .BulkAPI}} +
+ {{index .Text "bulk_inactive_title"}} +

{{index .Text "bulk_inactive_help"}}

+
+ {{end}} + +
+
+
+

{{index .Text "bulk_workspace_eyebrow"}}

+

{{index .Text "bulk_workspace_title"}}

+

{{index .Text "bulk_workspace_intro"}}

+
+
+ + {{if .BulkRequireAPIKey}} +
+ +
+ {{end}} + +
+
+ +
+ + 0 / {{.BulkMaxItems}} +
+
+ +
+

{{index .Text "bulk_profile_title"}}

+

{{index .Text "bulk_profile_intro"}}

+
+ + + +
Text
+
{{index .Text "bulk_cover_image"}}
+
{{index .Text "bulk_images"}}
+
{{index .Text "bulk_research"}}
+
{{index .Text "bulk_translation"}}
+
Code
+
+
+
+ + + +
+ + +
+
+ +
+

{{index .Text "bulk_advanced_eyebrow"}}

+

{{index .Text "bulk_advanced_title"}}

+

{{index .Text "bulk_advanced_intro"}}

+ +
+ + +
+
+ + +
+ + + + + + +{{end}} diff --git a/web/templates/declaration.html b/web/templates/declaration.html index 9dea3bc..8ad33fd 100644 --- a/web/templates/declaration.html +++ b/web/templates/declaration.html @@ -76,7 +76,7 @@
{{index .Text "transparency_label"}} {{index .Text "transparency_text"}}
- + {{end}} diff --git a/web/templates/index.html b/web/templates/index.html index b3365a5..fa07f57 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -153,7 +153,7 @@

HA

{{index .Text "api_stateless_title"}}

{{index .Text "api_stateless_desc"}}

-
{{if not .WhiteLabel}}AI Usage Disclosure · {{end}}App 2.0.0 · Schema 1.2 · {{.License.Edition}}{{index .Text "footer_no_legal"}}
+ diff --git a/web/templates/marketing.html b/web/templates/marketing.html index 02c9f5c..bda8c05 100644 --- a/web/templates/marketing.html +++ b/web/templates/marketing.html @@ -119,7 +119,7 @@ -
{{if not .WhiteLabel}}{{.Marketing.Footer}}{{end}}App 2.0.0 · Schema 1.2 · {{.License.Edition}}
+ diff --git a/web/templates/operator.html b/web/templates/operator.html new file mode 100644 index 0000000..5827e98 --- /dev/null +++ b/web/templates/operator.html @@ -0,0 +1,75 @@ +{{define "operator.html"}} + + + + + + {{.LegalPage.Title}} · {{.Name}} + + + {{range .Languages}}{{end}} + + + + + + +
+
+

{{.Name}}

+

{{.LegalPage.Title}}

+

{{.LegalPage.Lead}}

+
+ +
+ {{range .LegalPage.Sections}} +
+

{{.Title}}

+ {{range .Paragraphs}}

{{.}}

{{end}} + {{if .Items}} +
+ {{range .Items}} +
{{.Label}}
{{if .URL}}{{.Value}}{{else}}{{.Value}}{{end}}
+ {{end}} +
+ {{end}} +
+ {{end}} +
+ + +
+ + + + + +{{end}}