From c3c85eef212bbc0883a105e2b70ea514100f0a4b Mon Sep 17 00:00:00 2001 From: jbergner Date: Mon, 20 Jul 2026 21:03:05 +0200 Subject: [PATCH] init --- .dockerignore | 10 + .env.example | 16 + .gitea/workflows/registry.yml | 51 + .gitignore | 8 + CHANGELOG.md | 105 ++ Dockerfile | 19 + LICENSE | 21 + Makefile | 24 + README.md | 219 +++- SECURITY.md | 16 + cmd/server/main.go | 70 ++ compose.yaml | 38 + deploy/kubernetes.yaml | 175 ++++ deploy/license-server-kubernetes.yaml | 81 ++ deploy/swarm-stack.yaml | 70 ++ docs/COMMERCIAL-BOUNDARY.md | 9 + docs/LICENSE-CLIENT.md | 40 + docs/LICENSE-INTEGRATION.md | 172 +++ docs/MARKETING-PAGE.md | 35 + docs/MIGRATION-1.5-TO-1.6.md | 40 + docs/PRODUCT-PAGE.md | 10 + go.mod | 7 + internal/app/config.go | 99 ++ internal/app/licensing.go | 27 + internal/app/metrics.go | 42 + internal/app/server.go | 988 ++++++++++++++++++ internal/app/server_test.go | 223 ++++ internal/app/trusted_keys.json | 1 + internal/badge/render.go | 108 ++ internal/badge/render_test.go | 19 + internal/declaration/model.go | 343 ++++++ internal/declaration/model_test.go | 114 ++ internal/i18n/catalog.go | 430 ++++++++ internal/i18n/marketing.go | 22 + internal/licensing/licensing.go | 247 +++++ internal/licensing/licensing_test.go | 63 ++ internal/marketing/content.go | 601 +++++++++++ internal/marketing/content_test.go | 25 + openapi-license-server.yaml | 74 ++ openapi.yaml | 207 ++++ pkg/licenseclient/client.go | 391 +++++++ pkg/licenseclient/client_test.go | 81 ++ pkg/licenseclient/doc.go | 4 + pkg/licensekit/doc.go | 3 + pkg/licensekit/licensekit.go | 472 +++++++++ pkg/licensekit/licensekit_test.go | 84 ++ pkg/licenseserver/doc.go | 4 + pkg/licenseserver/server.go | 271 +++++ pkg/licenseserver/server_test.go | 91 ++ pkg/licenseserver/store.go | 130 +++ run.ps1 | 35 + schema/declaration.schema.json | 55 + schema/license.schema.json | 34 + schema/trust-store.schema.json | 12 + third_party/license-platform-client/LICENSE | 21 + third_party/license-platform-client/README.md | 16 + third_party/license-platform-client/go.mod | 3 + .../pkg/licensekit/doc.go | 3 + .../pkg/licensekit/licensekit.go | 408 ++++++++ .../pkg/licensekit/licensekit_test.go | 35 + .../sdk/go/licenseclient/client.go | 439 ++++++++ .../sdk/go/licenseclient/client_test.go | 64 ++ .../sdk/go/licenseclient/doc.go | 4 + web/embed.go | 8 + web/static/app.js | 126 +++ web/static/marketing.js | 22 + web/static/style.css | 39 + web/templates/declaration.html | 60 ++ web/templates/index.html | 140 +++ web/templates/marketing.html | 126 +++ 70 files changed, 8049 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/registry.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 cmd/server/main.go create mode 100644 compose.yaml create mode 100644 deploy/kubernetes.yaml create mode 100644 deploy/license-server-kubernetes.yaml create mode 100644 deploy/swarm-stack.yaml create mode 100644 docs/COMMERCIAL-BOUNDARY.md create mode 100644 docs/LICENSE-CLIENT.md create mode 100644 docs/LICENSE-INTEGRATION.md create mode 100644 docs/MARKETING-PAGE.md create mode 100644 docs/MIGRATION-1.5-TO-1.6.md create mode 100644 docs/PRODUCT-PAGE.md create mode 100644 go.mod create mode 100644 internal/app/config.go create mode 100644 internal/app/licensing.go create mode 100644 internal/app/metrics.go create mode 100644 internal/app/server.go create mode 100644 internal/app/server_test.go create mode 100644 internal/app/trusted_keys.json create mode 100644 internal/badge/render.go create mode 100644 internal/badge/render_test.go create mode 100644 internal/declaration/model.go create mode 100644 internal/declaration/model_test.go create mode 100644 internal/i18n/catalog.go create mode 100644 internal/i18n/marketing.go create mode 100644 internal/licensing/licensing.go create mode 100644 internal/licensing/licensing_test.go create mode 100644 internal/marketing/content.go create mode 100644 internal/marketing/content_test.go create mode 100644 openapi-license-server.yaml create mode 100644 openapi.yaml create mode 100644 pkg/licenseclient/client.go create mode 100644 pkg/licenseclient/client_test.go create mode 100644 pkg/licenseclient/doc.go create mode 100644 pkg/licensekit/doc.go create mode 100644 pkg/licensekit/licensekit.go create mode 100644 pkg/licensekit/licensekit_test.go create mode 100644 pkg/licenseserver/doc.go create mode 100644 pkg/licenseserver/server.go create mode 100644 pkg/licenseserver/server_test.go create mode 100644 pkg/licenseserver/store.go create mode 100644 run.ps1 create mode 100644 schema/declaration.schema.json create mode 100644 schema/license.schema.json create mode 100644 schema/trust-store.schema.json create mode 100644 third_party/license-platform-client/LICENSE create mode 100644 third_party/license-platform-client/README.md create mode 100644 third_party/license-platform-client/go.mod create mode 100644 third_party/license-platform-client/pkg/licensekit/doc.go create mode 100644 third_party/license-platform-client/pkg/licensekit/licensekit.go create mode 100644 third_party/license-platform-client/pkg/licensekit/licensekit_test.go create mode 100644 third_party/license-platform-client/sdk/go/licenseclient/client.go create mode 100644 third_party/license-platform-client/sdk/go/licenseclient/client_test.go create mode 100644 third_party/license-platform-client/sdk/go/licenseclient/doc.go create mode 100644 web/embed.go create mode 100644 web/static/app.js create mode 100644 web/static/marketing.js create mode 100644 web/static/style.css create mode 100644 web/templates/declaration.html create mode 100644 web/templates/index.html create mode 100644 web/templates/marketing.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2201893 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +*.zip +*.tar.gz +coverage.out +bin +data +.env +*.key +*.private diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..21a0072 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +BASE_URL=http://localhost:8080 +PUBLIC_NAME=AI Usage Disclosure +CONTACT_URL=https://b1tsblog.org/page/ai +DEFAULT_LANGUAGE=de +TRUST_PROXY=true + +# 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 + diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..cfe785d --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: sendnrw + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."git.send.nrw"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: git.send.nrw # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..313a672 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/bin/ +/coverage.out +/data/ +*.log +.env +*.key +*.private +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b531577 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,105 @@ +# Changelog + +## 1.6.1 + +- Added a generator control for the declaration evidence basis: `selfDeclared`, `technicallyRecorded`, `signed`, and `verified`. +- The selected evidence basis is now included in declaration URLs, HTML pages, SVG links, and JSON-LD manifests. +- Reworked declaration summaries with more professional, contextual wording and a clear explanation of the selected evidence basis. +- Added a scope note for digitally signed declarations: signatures prove origin and integrity, not substantive correctness. +- Improved German and English preset descriptions and article-level headings. +- Added a full cell grid, fixed column proportions, consistent alignment, and responsive overflow to component tables. +- Restyled declaration metadata as a consistent two-column grid. +- Documented the assurance parameter in OpenAPI and added regression tests for all supported values. + +## 1.6.0 + +- removed `cmd/licensectl`, `cmd/licenseweb`, `cmd/licenseserver` and all embedded license-authority/server code; +- removed private-key handling, key generation, token signing, license registry storage and lease signing from the product project; +- replaced the previous internal licensing implementation with a verification-only client snapshot compatible with Universal License Platform v1.0.0; +- switched online verification to `POST /api/v1/licenses/validate` with legacy `/v1/introspect` fallback; +- added support for the platform-signed `verification.serverUrl` claim and well-known discovery; +- retained only the build-time public trust store, runtime token verification, feature/limit gates and signed hybrid lease cache; +- removed the runtime insecure-Pro environment override so official builds always require a platform-issued token; +- removed license-server Docker, Compose, Kubernetes and OpenAPI artifacts from this project; +- documented the standalone platform as the sole license issuance and administration authority. + +## 1.5.0 + +- replaced customer-configurable public keys with a build-time embedded trust store; +- added universal `pkg/licensekit` and `pkg/licenseclient` packages; +- added product-bound licences with arbitrary features, limits, domains and instance IDs; +- added offline, hybrid and online verification modes; +- added a central introspection and revocation server with signed short-lived leases; +- expanded the local licence web interface for multiple products and optional central registration; +- added separate issuer and lease keys with key IDs and rotation support; +- removed `LICENSE_PUBLIC_KEY` from application, Docker, Kubernetes and Swarm configuration; +- added reference deployment and architecture documentation. + +## 1.4.1 + +- `*` is now accepted as a global licensed-domain wildcard. +- The license web interface documents the difference between `*` and `*.example.org`. +- Added regression coverage for arbitrary public domains and localhost. + +## 1.3.3 + +- Changed the default right-hand colour of article-level “Artikeltransparenz” badges from an extent-derived warning colour to a calmer violet (`#7c3aed`). +- Individual declaration colours remain unchanged. +- Explicit Pro badge colours continue to override the article default. + +## 1.3.2 + +- Sprachumschalter direkt auf Deklarationsseiten hinzugefügt. +- Beim Sprachwechsel bleiben sämtliche Artikel-, Review-, Assurance- und Pro-Parameter erhalten. +- Serverseitig gerenderte Sprachlinks als Fallback ohne JavaScript. +- `hreflang`-Links für alle acht Sprachen und `x-default` ergänzt. +- Responsive Darstellung des Sprachumschalters innerhalb der Ergebnis-Karte. + + +## 1.3.1 + +- Artikelkomponenten besitzen nun eine eigene Auswahl für die menschliche Prüfung. +- Der Generator überträgt die gewählte Prüfart statt immer `editorial` zu setzen. +- Komponenten ohne KI erhalten standardmäßig `humanReview: none`. +- Zusammenfassungstexte behaupten bei Komponenten ohne KI keine redaktionelle KI-Prüfung mehr. + + +## 1.3.0 + +- Added article-level declarations that combine text, images, research, translation, audio, video and code under one link. +- Added automatically generated 5–10 sentence narrative summaries alongside structured component tables. +- Added a generator switch between single-use and article/site declarations. +- Improved declaration-page typography, responsive headline containment and table layout. +- Article declarations remain available through HTML, SVG badge and JSON-LD endpoints. + + +## 1.2.0 + +- Added a responsive marketing page at `/product`. +- Added `/pricing` and `/install` section redirects. +- Added localized product, feature, edition, pricing, installation and FAQ copy for all eight supported languages. +- Added Community/Pro comparison and configurable pricing labels. +- Added Docker Compose, Docker CLI, Kubernetes, Docker Swarm and native Go installation instructions with copy controls. +- Added environment-variable reference and deployment FAQ. +- Added `SALES_URL`, `PRICE_COMMUNITY`, `PRICE_PRO`, `PRICE_PUBLISHER` and `PRICE_AGENCY`. +- Added a product/pricing link to the generator navigation. +- Added route and language-negotiation tests for the marketing page. + +## 1.1.0 + +- Added German, English, French, Spanish, Italian, Dutch, Portuguese and Polish language catalogs. +- Added `Accept-Language` negotiation and `DEFAULT_LANGUAGE`. +- Localized presets, disclosure values, generator UI and declaration pages. +- Added optional `presentation` data to schema 1.1 while retaining validator compatibility for legacy 1.0 declarations. +- Added Pro capabilities `custom_text`, `custom_badge` and reserved `white_label`. +- Added custom declaration titles and descriptions. +- Added custom badge labels and six-digit hex colours. +- Added offline Ed25519 license verification, expiration and domain restrictions. +- Added `cmd/licensectl` for key generation and license signing. +- Added `/v1/capabilities`. +- Added Kubernetes optional Secret integration and deployment variables for Pro. +- Added server-side `403 pro_feature_required` enforcement. + +## 1.0.0 + +- Initial stateless badge, declaration, JSON-LD and validation service. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afa77bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1.7 +FROM golang:1.23-alpine AS build +WORKDIR /src +RUN apk add --no-cache ca-certificates tzdata +COPY go.mod ./ +COPY third_party ./third_party +COPY cmd ./cmd +COPY internal ./internal +COPY web ./web +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server + +FROM scratch +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=build /out/ai-disclosure /ai-disclosure +USER 65532:65532 +EXPOSE 8080 +ENV LISTEN_ADDRESS=:8080 LICENSE_CACHE_FILE=/data/license-lease.json +ENTRYPOINT ["/ai-disclosure"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..12feb38 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AI Usage Disclosure contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..36eb4b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: run test test-license-client check build docker-build + +run: + go run ./cmd/server + +test: + go test ./... + cd third_party/license-platform-client && go test ./... + +test-license-client: + cd third_party/license-platform-client && go test ./... + +check: + gofmt -w $$(find cmd internal web third_party/license-platform-client -name '*.go' -type f) + go vet ./... + go test -race ./... + cd third_party/license-platform-client && go vet ./... && go test -race ./... + +build: + mkdir -p bin + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure ./cmd/server + +docker-build: + docker build -t ai-disclosure-standard:1.6.1-local . diff --git a/README.md b/README.md index b82f246..fee7f0a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,219 @@ -# ai-disclosure-standard +# AI Disclosure Standard 1.6.1 +Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen. + +## Funktionen + +- einzelne Deklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen; +- SVG-Badges, HTML-Erklärung und JSON-LD unter einem gemeinsamen Link; +- Text, Titelbild, weitere Bilder, Recherche, Übersetzung, Audio, Video und Code getrennt erfassbar; +- automatisch erzeugte, professionell formulierte Zusammenfassung als Fließtext und strukturierte Tabelle; +- frei wählbare Nachweisgrundlage: Selbsterklärung, technisch protokolliert, signiert oder verifiziert; +- Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Portugiesisch und Polnisch; +- Sprachumschalter direkt auf der Ergebnisseite; +- optionale lizenzierte Funktionen für eigene Texte und Badge-Darstellung; +- Offline-, Hybrid- und Online-Prüfung über die eigenständige Universal License Platform; +- Health-, Readiness- und Prometheus-Endpunkte; +- Docker-, Kubernetes- und Docker-Swarm-Deployment. + +## Start unter Windows + +```powershell +go run .\cmd\server +``` + +Danach: + +```text +Generator: http://localhost:8080/ +Produktseite: http://localhost:8080/product +Healthcheck: http://localhost:8080/healthz +Funktionen: http://localhost:8080/v1/capabilities +``` + +Go lädt `.env` nicht selbst. Unter PowerShell kann die Datei vor dem Start in die Prozessumgebung übernommen werden oder Docker Compose mit `--env-file .env` verwendet werden. + +## Docker Compose + +```powershell +Copy-Item .env.example .env +docker compose --env-file .env up -d --build +``` + +## Lizenzprüfung + +Dieses Projekt stellt **keine Lizenzen aus** und enthält keine Schlüsselgenerierung, privaten Schlüssel, Lizenzverwaltung, Admin-Oberfläche oder eigenen Lizenzserver. Diese Aufgaben gehören ausschließlich in die separat betriebene **Universal License Platform**. + +Der Produktserver enthält nur den Laufzeit-Client und akzeptiert: + +```env +LICENSE_TOKEN=... +LICENSE_MODE=offline +LICENSE_SERVER_URL= +LICENSE_INSTANCE_ID= +``` + +Der Produktname ist fest verdrahtet: + +```text +ai-disclosure-standard +``` + +Für die vorhandenen Funktionen verwendet die Plattform diese Feature-IDs: + +```text +custom_text +custom_badge +white_label +``` + +### Vertrauensschlüssel einbetten + +Die öffentlichen Issuer- und Lease-Schlüssel werden von der Universal License Platform bereitgestellt. Lade dort den Trust Store herunter und ersetze vor dem Build: + +```text +internal/app/trusted_keys.json +``` + +PowerShell-Beispiel: + +```powershell +Invoke-WebRequest ` + "https://licenses.example.org/api/v1/trust-store" ` + -OutFile ".\internal\app\trusted_keys.json" + +go build .\cmd\server +``` + +Der Trust Store wird mit `go:embed` fest in das Binary eingebaut. Es gibt absichtlich kein `LICENSE_PUBLIC_KEY` und keinen zur Laufzeit austauschbaren Trust Store. + +### Prüfmodi + +**Offline** prüft den signierten Lizenz-Token ausschließlich lokal. + +```env +LICENSE_MODE=offline +LICENSE_TOKEN=... +``` + +**Hybrid** fragt die Universal License Platform ab und speichert ein kurzlebiges, signiertes Lease. Bei temporärer Nichterreichbarkeit kann das letzte gültige Lease innerhalb der in der Lizenz festgelegten Grace-Periode verwendet werden. + +```env +LICENSE_MODE=hybrid +LICENSE_TOKEN=... +LICENSE_CACHE_FILE=/data/license-lease.json +``` + +**Online** benötigt eine erfolgreiche aktuelle Prüfung durch die Plattform. + +```env +LICENSE_MODE=online +LICENSE_TOKEN=... +``` + +Bei Hybrid- und Online-Lizenzen übernimmt der Client bevorzugt die von der Plattform signiert in der Lizenz gespeicherte Server-URL. `LICENSE_SERVER_URL` ist nur ein expliziter Override beziehungsweise Fallback. + +Der Client verwendet die Plattform-API: + +```text +POST /api/v1/licenses/validate +``` + +und unterstützt für bestehende Installationen weiterhin: + +```text +POST /v1/introspect +``` + +Weitere Einzelheiten stehen in [`docs/LICENSE-INTEGRATION.md`](docs/LICENSE-INTEGRATION.md). Für bestehende 1.5-Installationen siehe [`docs/MIGRATION-1.5-TO-1.6.md`](docs/MIGRATION-1.5-TO-1.6.md). + +## Konfiguration + +| Variable | Standard | Bedeutung | +|---|---|---| +| `LISTEN_ADDRESS` | `:8080` | HTTP-Adresse | +| `BASE_URL` | `http://localhost:8080` | öffentliche kanonische URL und Domainprüfung | +| `PUBLIC_NAME` | `AI Usage Disclosure` | sichtbarer Produktname | +| `CONTACT_URL` | Projektseite | Kontakt-/Informationsseite | +| `DEFAULT_LANGUAGE` | `de` | Standardsprache | +| `TRUST_PROXY` | `false` | Proxy-Header für Client-IP berücksichtigen | +| `LICENSE_TOKEN` | leer | von der Universal License Platform ausgestellter Token | +| `LICENSE_MODE` | `offline` | Mindestmodus `offline`, `hybrid` oder `online` | +| `LICENSE_SERVER_URL` | leer | optionaler Prüfserver-Override | +| `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 | + +## API + +```text +GET /badge/{preset}.svg +GET /v1/badge.svg +GET /declaration +GET /v1/declaration.json +POST /v1/validate +GET /v1/capabilities +GET /healthz +GET /readyz +GET /metrics +``` + +Beispiel für eine Artikelerklärung: + +```text +/declaration?mode=article&textExtent=none&textReview=none&imageExtent=full&imageReview=editorial&researchExtent=assisted&researchReview=expert&assurance=technicallyRecorded&lang=de +``` + + +### Nachweisgrundlage + +Der Generator bietet vier interoperable Werte. Sie werden über den Query-Parameter `assurance` an HTML- und JSON-LD-Ausgaben übertragen: + +| Wert | Bedeutung | +|---|---| +| `selfDeclared` | Die veröffentlichende Person oder Organisation stellt die Angaben selbst bereit. | +| `technicallyRecorded` | Die Angaben wurden im Erstellungs- oder Veröffentlichungsprozess technisch protokolliert. | +| `signed` | Die Erklärung wurde digital signiert; Herkunft und Unverändertheit können geprüft werden. | +| `verified` | Die Angaben wurden nach einem dokumentierten Verfahren zusätzlich verifiziert. | + +Eine digitale Signatur bestätigt die Herkunft und Integrität der Erklärung, nicht automatisch die inhaltliche Richtigkeit ihrer Angaben. Nicht zutreffende Nachweisstufen sollten nicht ausgewählt werden. + +## Entwicklung und Prüfung + +```powershell +go test .\... +go vet .\... +``` + +Der eingebundene, reine Laufzeit-Client wird separat geprüft: + +```powershell +Set-Location .\third_party\license-platform-client +go test .\... +``` + +Gesamtprüfung über Make: + +```bash +make check +``` + +## Projektgrenze + +Im Hauptprojekt verbleiben ausschließlich: + +- ein eingebetteter öffentlicher Trust Store; +- ein verifikationsfähiger Client; +- Feature- und Limit-Abfragen; +- optionaler signierter Lease-Cache. + +Nicht enthalten sind: + +- private Schlüssel; +- Keygen oder Lizenzsignierung; +- Lizenzportal oder Admin-API; +- Lizenzdatenbank; +- Widerrufsverwaltung oder Lease-Signierung. + +Diese Funktionen werden nur in der eigenständigen Universal License Platform betrieben. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f147454 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Security policy + +Version 1.6.x is the supported line in this project archive. + +## License integration + +- Customer installations configure only `LICENSE_TOKEN` and optional client settings. +- `LICENSE_PUBLIC_KEY`, private keys and signing keys are not supported. +- Public issuer and lease keys are embedded from `internal/app/trusted_keys.json` at build time. +- Key generation, license issuance, token registries, revocation and lease signing exist only in the standalone Universal License Platform. +- Use HTTPS for hybrid and online verification. +- Protect the hybrid cache directory from other local users; it contains only signed lease tokens, not private keys. + +Online mode fails closed if the platform is unavailable. Hybrid mode may continue only while a previously signed lease remains valid within the grace period encoded in the license. + +Report suspected vulnerabilities privately to the project operator before public disclosure. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..14ef5be --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/b1tsblog/ai-disclosure-standard/internal/app" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "--healthcheck" { + url := os.Getenv("HEALTHCHECK_URL") + if url == "" { + url = "http://127.0.0.1:8080/healthz" + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } + _ = resp.Body.Close() + return + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + cfg := app.ConfigFromEnv() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + handler, err := app.New(ctx, cfg, logger) + if err != nil { + logger.Error("application initialization failed", "error", err) + os.Exit(1) + } + + server := &http.Server{ + Addr: cfg.ListenAddress, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + go func() { + logger.Info("server started", "address", cfg.ListenAddress, "base_url", cfg.BaseURL) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("server failed", "error", err) + os.Exit(1) + } + }() + + <-ctx.Done() + logger.Info("shutdown requested") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Error("graceful shutdown failed", "error", err) + os.Exit(1) + } + logger.Info("server stopped") +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..11811f6 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,38 @@ +services: + app: + build: . + image: ai-disclosure-standard:1.6.1-local + environment: + BASE_URL: "${BASE_URL:-http://localhost:8080}" + PUBLIC_NAME: "${PUBLIC_NAME:-AI Usage Disclosure}" + CONTACT_URL: "${CONTACT_URL:-https://b1tsblog.org/page/ai}" + DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}" + TRUST_PROXY: "${TRUST_PROXY:-true}" + LICENSE_TOKEN: "${LICENSE_TOKEN:-}" + LICENSE_MODE: "${LICENSE_MODE:-offline}" + LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}" + LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-}" + LICENSE_CACHE_FILE: /data/license-lease.json + LICENSE_REFRESH_INTERVAL: "${LICENSE_REFRESH_INTERVAL:-15m}" + LICENSE_REQUEST_TIMEOUT: "${LICENSE_REQUEST_TIMEOUT:-5s}" + ports: + - "8080:8080" + volumes: + - license-cache:/data + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped + healthcheck: + test: ["CMD", "/ai-disclosure", "--healthcheck"] + interval: 15s + timeout: 3s + retries: 3 + start_period: 5s + +volumes: + license-cache: diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml new file mode 100644 index 0000000..57f973d --- /dev/null +++ b/deploy/kubernetes.yaml @@ -0,0 +1,175 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ai-disclosure + labels: + app.kubernetes.io/name: ai-disclosure +spec: + replicas: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: ai-disclosure + template: + metadata: + labels: + app.kubernetes.io/name: ai-disclosure + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: /metrics + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: app + image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.1 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + env: + - name: BASE_URL + value: https://ai.example.org + - name: PUBLIC_NAME + value: AI Usage Disclosure + - name: CONTACT_URL + value: https://b1tsblog.org/page/ai + - name: SALES_URL + value: https://b1tsblog.org/page/ai + - name: DEFAULT_LANGUAGE + value: de + - name: TRUST_PROXY + value: "true" + - name: LICENSE_TOKEN + valueFrom: + secretKeyRef: + name: ai-disclosure-license + key: token + optional: true + - name: LICENSE_MODE + value: offline + - name: LICENSE_SERVER_URL + value: "" + - name: LICENSE_INSTANCE_ID + valueFrom: + fieldRef: + fieldPath: metadata.uid + - name: LICENSE_CACHE_FILE + value: /data/license-lease.json + volumeMounts: + - name: license-cache + mountPath: /data + resources: + requests: + cpu: 25m + memory: 24Mi + limits: + cpu: 500m + memory: 128Mi + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 2 + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: license-cache + emptyDir: {} + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: ai-disclosure +--- +apiVersion: v1 +kind: Service +metadata: + name: ai-disclosure +spec: + selector: + app.kubernetes.io/name: ai-disclosure + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: ai-disclosure +spec: + minAvailable: 2 + selector: + matchLabels: + app.kubernetes.io/name: ai-disclosure +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: ai-disclosure +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: ai-disclosure + minReplicas: 3 + maxReplicas: 12 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 65 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ai-disclosure + annotations: + nginx.ingress.kubernetes.io/proxy-read-timeout: "15" + nginx.ingress.kubernetes.io/proxy-send-timeout: "15" + nginx.ingress.kubernetes.io/limit-rps: "50" +spec: + ingressClassName: nginx + tls: + - hosts: [ai.example.org] + secretName: ai-disclosure-tls + rules: + - host: ai.example.org + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ai-disclosure + port: + name: http diff --git a/deploy/license-server-kubernetes.yaml b/deploy/license-server-kubernetes.yaml new file mode 100644 index 0000000..8f0e1d2 --- /dev/null +++ b/deploy/license-server-kubernetes.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: universal-license-server +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: universal-license-server + template: + metadata: + labels: + app.kubernetes.io/name: universal-license-server + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: server + image: ghcr.io/REPLACE_ME/universal-license-server:1.5.0 + ports: + - name: http + containerPort: 8091 + env: + - name: LICENSE_SERVER_ADDRESS + value: :8091 + - name: LICENSE_SERVER_DATA + value: /data/licenses.json + - name: LICENSE_TRUST_STORE_FILE + value: /config/trusted-keys.json + - name: LEASE_SIGNING_KEY_ID + value: lease-2026 + - name: LEASE_SIGNING_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: universal-license-secrets + key: lease-private-key + - name: LICENSE_SERVER_ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: universal-license-secrets + key: admin-token + volumeMounts: + - name: data + mountPath: /data + - name: trust-store + mountPath: /config + readOnly: true + readinessProbe: + httpGet: + path: /healthz + port: http + livenessProbe: + httpGet: + path: /healthz + port: http + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: data + persistentVolumeClaim: + claimName: universal-license-data + - name: trust-store + configMap: + name: universal-license-trust-store +--- +apiVersion: v1 +kind: Service +metadata: + name: universal-license-server +spec: + selector: + app.kubernetes.io/name: universal-license-server + ports: + - name: http + port: 80 + targetPort: http diff --git a/deploy/swarm-stack.yaml b/deploy/swarm-stack.yaml new file mode 100644 index 0000000..864c844 --- /dev/null +++ b/deploy/swarm-stack.yaml @@ -0,0 +1,70 @@ +version: "3.9" +services: + app: + image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.1 + environment: + BASE_URL: https://ai.example.org + PUBLIC_NAME: AI Usage Disclosure + CONTACT_URL: https://b1tsblog.org/page/ai + SALES_URL: "${SALES_URL:-https://b1tsblog.org/page/ai}" + DEFAULT_LANGUAGE: de + TRUST_PROXY: "true" + LICENSE_TOKEN: "${LICENSE_TOKEN:-}" + LICENSE_MODE: "${LICENSE_MODE:-offline}" + LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}" + LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-swarm}" + LICENSE_CACHE_FILE: /data/license-lease.json + ports: + - target: 8080 + published: 8080 + protocol: tcp + mode: ingress + networks: + - public + volumes: + - license-cache:/data + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + cap_drop: + - ALL + healthcheck: + test: ["CMD", "/ai-disclosure", "--healthcheck"] + interval: 15s + timeout: 3s + retries: 3 + start_period: 5s + deploy: + mode: replicated + replicas: 3 + endpoint_mode: vip + update_config: + parallelism: 1 + delay: 5s + order: start-first + failure_action: rollback + rollback_config: + parallelism: 1 + order: stop-first + restart_policy: + condition: on-failure + delay: 3s + max_attempts: 5 + window: 30s + placement: + preferences: + - spread: node.labels.zone + resources: + reservations: + cpus: "0.05" + memory: 24M + limits: + cpus: "0.50" + memory: 128M +networks: + public: + driver: overlay + attachable: true + +volumes: + license-cache: diff --git a/docs/COMMERCIAL-BOUNDARY.md b/docs/COMMERCIAL-BOUNDARY.md new file mode 100644 index 0000000..b676d70 --- /dev/null +++ b/docs/COMMERCIAL-BOUNDARY.md @@ -0,0 +1,9 @@ +# Commercial boundary + +The declaration renderer and standard remain usable without a commercial entitlement. Licensed capabilities are enabled only by tokens issued through the separately operated Universal License Platform. + +Official binaries embed a public trust store at build time. Customers can configure only a signed `LICENSE_TOKEN` and optional runtime verification settings. They cannot replace the trusted issuer or lease keys through environment variables. + +The AI Disclosure Standard repository does not contain private keys, key generation, license issuance, administration, revocation storage or lease signing. Those responsibilities belong to the standalone platform. + +A technically capable user can still modify open source and compile an unofficial binary. Legal licence terms, trademark protection, official signed releases, support, updates and centrally verified services remain the enforceable commercial boundary. diff --git a/docs/LICENSE-CLIENT.md b/docs/LICENSE-CLIENT.md new file mode 100644 index 0000000..d2af783 --- /dev/null +++ b/docs/LICENSE-CLIENT.md @@ -0,0 +1,40 @@ +# Runtime license client + +The application uses the client protocol from Universal License Platform v1.0.0. + +Imports: + +```go +import ( + "github.com/b1tsblog/license-platform/pkg/licensekit" + "github.com/b1tsblog/license-platform/sdk/go/licenseclient" +) +``` + +Initialization is performed in `internal/app/server.go`. The product ID and embedded trust store are not customer-configurable. + +```go +licenses := licenseclient.New(ctx, licenseclient.Config{ + Product: "ai-disclosure-standard", + ClientVersion: "1.6.1", + Token: cfg.LicenseToken, + TrustStore: trustStore, + BaseURL: cfg.BaseURL, + InstanceID: cfg.LicenseInstanceID, + Mode: cfg.LicenseMode, + ServerURL: cfg.LicenseServerURL, + CacheFile: cfg.LicenseCacheFile, + RefreshEvery: cfg.LicenseRefreshEvery, + RequestTimeout: cfg.LicenseTimeout, +}) +``` + +Feature checks remain server-side: + +```go +if licenses.Has("custom_badge") { + // accept custom badge presentation +} +``` + +The runtime client contains no license-signing or private-key functionality. See [`LICENSE-INTEGRATION.md`](LICENSE-INTEGRATION.md) for deployment details. diff --git a/docs/LICENSE-INTEGRATION.md b/docs/LICENSE-INTEGRATION.md new file mode 100644 index 0000000..923737e --- /dev/null +++ b/docs/LICENSE-INTEGRATION.md @@ -0,0 +1,172 @@ +# Integration mit der Universal License Platform + +## Verantwortungsgrenze + +Die AI-Disclosure-Anwendung ist ausschließlich ein Lizenz-Client. Die Universal License Platform ist die einzige Autorität für: + +- Schlüsselverwaltung; +- Produkterfassung; +- Lizenzausstellung; +- Token-Registry; +- Widerruf und Reaktivierung; +- Onlineprüfung; +- Signierung kurzlebiger Leases; +- Audit- und Benutzerverwaltung. + +Das Hauptprojekt enthält keine dieser Funktionen. + +## Produkt in der Plattform anlegen + +Verwende als unveränderliche Produkt-ID: + +```text +ai-disclosure-standard +``` + +Die aktuell ausgewerteten Feature-IDs sind: + +| Feature | Wirkung | +|---|---| +| `custom_text` | eigener Titel und eigener Erklärungstext | +| `custom_badge` | eigene Badge-Beschriftungen und Farben | +| `white_label` | reserviert für markenneutrale Ausgaben | + +Unbekannte Features werden vom Produkt ignoriert und können für andere Produkte weiterverwendet werden. + +## Trust Store installieren + +Die Plattform liefert unter folgendem Endpunkt ausschließlich öffentliche Schlüssel: + +```text +GET /api/v1/trust-store +``` + +Die Antwort muss vor dem Produkt-Build als folgende Datei gespeichert werden: + +```text +internal/app/trusted_keys.json +``` + +Erwartetes Format: + +```json +{ + "licenseKeys": { + "issuer-2026": "PUBLIC_KEY_BASE64URL" + }, + "leaseKeys": { + "lease-2026": "PUBLIC_KEY_BASE64URL" + } +} +``` + +Anschließend muss das Produkt neu kompiliert beziehungsweise das Container-Image neu gebaut werden. Der Trust Store ist absichtlich nicht per Umgebungsvariable konfigurierbar. + +## Runtime-Konfiguration + +Minimal für Offline-Lizenzen: + +```env +LICENSE_TOKEN=... +LICENSE_MODE=offline +``` + +Hybrid: + +```env +LICENSE_TOKEN=... +LICENSE_MODE=hybrid +LICENSE_CACHE_FILE=/data/license-lease.json +LICENSE_REFRESH_INTERVAL=15m +LICENSE_REQUEST_TIMEOUT=5s +``` + +Online: + +```env +LICENSE_TOKEN=... +LICENSE_MODE=online +LICENSE_REQUEST_TIMEOUT=5s +``` + +Optional: + +```env +LICENSE_SERVER_URL=https://licenses.example.org +LICENSE_INSTANCE_ID=production-eu-1 +``` + +Die in der Lizenz signierte `verification.serverUrl` wird automatisch erkannt. Die Auflösungsreihenfolge ist: + +1. `licenseclient.Config.ServerURL` beziehungsweise `LICENSE_SERVER_URL`; +2. signierte `verification.serverUrl` aus dem Lizenz-Token; +3. `/.well-known/license-server` auf der Produkt-Basis-URL. + +## Onlineprotokoll + +Der Client sendet an: + +```text +POST /api/v1/licenses/validate +``` + +Anfrage: + +```json +{ + "token": "...", + "product": "ai-disclosure-standard", + "baseUrl": "https://ai.example.org", + "host": "ai.example.org", + "instanceId": "production-eu-1", + "clientVersion": "1.6.1" +} +``` + +Erwartete Antwort: + +```json +{ + "valid": true, + "leaseToken": "..." +} +``` + +Der Lease-Token wird erneut lokal gegen die eingebetteten Lease-Public-Keys geprüft. Eine bloße positive JSON-Antwort ohne gültige Signatur schaltet keine Funktionen frei. + +## Domain- und Instanzbindung + +Die Plattform kann Lizenzen binden an: + +```text +example.org +*.example.org +* +``` + +`*` erlaubt alle Domains. `*.example.org` erlaubt nur echte Subdomains und nicht automatisch `example.org` selbst. + +Instanz-IDs funktionieren entsprechend. Ist eine Lizenz an Instanzen gebunden, muss `LICENSE_INSTANCE_ID` gesetzt sein. + +## Schlüsselrotation + +1. In der Universal License Platform einen neuen Issuer- oder Lease-Key aktivieren. +2. Einen Trust Store herunterladen, der alten und neuen Public Key enthält. +3. Das Produkt mit beiden Schlüsseln neu bauen und ausrollen. +4. Neue Lizenzen beziehungsweise Leases mit dem neuen Key ausstellen. +5. Den alten Public Key erst entfernen, wenn alle damit signierten Tokens abgelaufen oder ersetzt sind. + +## Client-Snapshot + +Unter `third_party/license-platform-client` liegt eine client-only, protokollkompatible Momentaufnahme des Go-SDK aus Universal License Platform v1.0.0. Sie enthält nur: + +- öffentliche Protokolltypen; +- Trust-Store-Parsing; +- Ed25519-Verifikation; +- Kontext-, Domain- und Instanzprüfung; +- Offline-/Hybrid-/Online-Client; +- signierten Lease-Cache. + +Private-Key-Handling, Signierfunktionen, Admin-Server und Persistenz wurden bewusst nicht übernommen. + +Sobald das eigenständige Modul über einen stabilen Go-Modul-Tag erreichbar ist, kann in `go.mod` der lokale `replace`-Eintrag entfernt und direkt die veröffentlichte SDK-Version verwendet werden. diff --git a/docs/MARKETING-PAGE.md b/docs/MARKETING-PAGE.md new file mode 100644 index 0000000..4657afe --- /dev/null +++ b/docs/MARKETING-PAGE.md @@ -0,0 +1,35 @@ +# Marketing page + +The product page is served by the same Go binary as the badge and declaration API. + +## Routes + +- `/product` — complete product, feature, pricing and installation page +- `/pricing` — redirects to `/product#pricing` +- `/install` — redirects to `/product#install` + +All routes support `?lang=de|en|fr|es|it|nl|pt|pl` and `Accept-Language` negotiation. + +## Runtime configuration + +```text +SALES_URL=https://example.org/contact +PRICE_COMMUNITY=0 € +PRICE_PRO=19 € +PRICE_PUBLISHER=79 € +PRICE_AGENCY=199 € +``` + +`SALES_URL` is used by paid-plan calls to action. When it is empty, the service falls back to `CONTACT_URL`. + +The page deliberately markets only capabilities that exist in the current build. Domain counts, support levels and commercial terms are offer definitions; adjust them before publication. + +## Editing content + +- Page structure: `web/templates/marketing.html` +- Styling: `web/static/style.css` +- Copy controls and language switch: `web/static/marketing.js` +- Localised product copy: `internal/marketing/content.go` +- Generator navigation label: `internal/i18n/marketing.go` + +No external fonts, scripts, images, analytics or cookies are required. diff --git a/docs/MIGRATION-1.5-TO-1.6.md b/docs/MIGRATION-1.5-TO-1.6.md new file mode 100644 index 0000000..142eada --- /dev/null +++ b/docs/MIGRATION-1.5-TO-1.6.md @@ -0,0 +1,40 @@ +# Migration von 1.5 auf 1.6 + +## Entfernte Bestandteile + +Aus dem AI-Disclosure-Projekt wurden vollständig entfernt: + +- lokale Schlüsselgenerierung und Token-Signierung; +- Lizenz-Webinterface; +- eingebauter Lizenz- und Widerrufsserver; +- Admin-API und lokale Lizenz-Registry; +- Lease-Signierung; +- zugehörige Docker-, Kubernetes-, OpenAPI- und Schema-Dateien. + +Diese Aufgaben übernimmt ausschließlich die eigenständige Universal License Platform. + +## Umstellung + +1. Universal License Platform v1.0.0 separat starten. +2. Vorhandene Issuer- und Lease-Schlüssel entsprechend deren Migrationsanleitung importieren. +3. In der Plattform das Produkt `ai-disclosure-standard` und die benötigten Features verwenden. +4. Den öffentlichen Trust Store aus `GET /api/v1/trust-store` herunterladen. +5. Die Datei als `internal/app/trusted_keys.json` in dieses Projekt kopieren. +6. Produkt-Binary oder Container neu bauen. +7. Beim Produkt nur noch `LICENSE_TOKEN` und gegebenenfalls Client-Einstellungen setzen. + +## Nicht mehr unterstützte Konfiguration + +Folgende Werte gehören nicht mehr zum Produktprojekt: + +```text +LICENSE_PUBLIC_KEY +LICENSE_PRIVATE_KEY +LEASE_SIGNING_PRIVATE_KEY +LEASE_SIGNING_KEY_ID +LICENSE_SERVER_ADMIN_TOKEN +LICENSE_SERVER_DATA +LICENSE_TRUST_STORE_FILE +``` + +Eine vom alten eingebauten Server verwendete `LICENSE_SERVER_URL` muss auf die neue Universal License Platform zeigen. Neue Hybrid-/Online-Lizenzen können deren öffentliche URL bereits signiert im Token enthalten. diff --git a/docs/PRODUCT-PAGE.md b/docs/PRODUCT-PAGE.md new file mode 100644 index 0000000..543ce83 --- /dev/null +++ b/docs/PRODUCT-PAGE.md @@ -0,0 +1,10 @@ +# Product and installation page + +The public `/product` page documents features and installation paths without prices or upgrade advertising. + +Routes: + +- `/product` — feature and installation overview; +- `/install` — redirects to `/product#install`. + +The public page does not expose license administration. Runtime capabilities are available through `/v1/capabilities`; all license creation and management is handled by the separately deployed Universal License Platform. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fa3b33c --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/b1tsblog/ai-disclosure-standard + +go 1.26 + +require github.com/b1tsblog/license-platform v1.0.0 + +replace github.com/b1tsblog/license-platform => ./third_party/license-platform-client diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..cfdd899 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,99 @@ +package app + +import ( + "os" + "strconv" + "strings" + "time" + + "github.com/b1tsblog/license-platform/pkg/licensekit" +) + +type Config struct { + ListenAddress string + BaseURL string + PublicName string + ContactURL string + SalesURL string + DefaultLanguage string + TrustProxy bool + + LicenseToken string + LicenseMode licensekit.VerificationMode + LicenseServerURL string + LicenseInstanceID string + LicenseCacheFile string + LicenseRefreshEvery time.Duration + LicenseTimeout time.Duration + + CommunityPrice string + ProPrice string + PublisherPrice string + AgencyPrice string +} + +func ConfigFromEnv() Config { + contactURL := env("CONTACT_URL", "https://b1tsblog.org/page/ai") + mode, err := licensekit.ParseMode(env("LICENSE_MODE", "offline")) + if err != nil { + mode = licensekit.ModeOffline + } + return Config{ + ListenAddress: env("LISTEN_ADDRESS", ":8080"), + BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"), + PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"), + ContactURL: contactURL, + SalesURL: env("SALES_URL", contactURL), + DefaultLanguage: env("DEFAULT_LANGUAGE", "de"), + TrustProxy: strings.EqualFold(env("TRUST_PROXY", "false"), "true"), + + LicenseToken: secretEnv("LICENSE_TOKEN"), + LicenseMode: mode, + LicenseServerURL: strings.TrimRight(env("LICENSE_SERVER_URL", ""), "/"), + LicenseInstanceID: env("LICENSE_INSTANCE_ID", ""), + LicenseCacheFile: env("LICENSE_CACHE_FILE", "./data/license-lease.json"), + LicenseRefreshEvery: durationEnv("LICENSE_REFRESH_INTERVAL", 15*time.Minute), + LicenseTimeout: durationEnv("LICENSE_REQUEST_TIMEOUT", 5*time.Second), + + CommunityPrice: env("PRICE_COMMUNITY", "0 €"), + ProPrice: env("PRICE_PRO", "19 €"), + PublisherPrice: env("PRICE_PUBLISHER", "79 €"), + AgencyPrice: env("PRICE_AGENCY", "199 €"), + } +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func secretEnv(key string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + path := strings.TrimSpace(os.Getenv(key + "_FILE")) + if path == "" { + return "" + } + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func durationEnv(key string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.Duration(seconds) * time.Second + } + return fallback +} diff --git a/internal/app/licensing.go b/internal/app/licensing.go new file mode 100644 index 0000000..964fcc9 --- /dev/null +++ b/internal/app/licensing.go @@ -0,0 +1,27 @@ +package app + +import ( + _ "embed" + "fmt" + + "github.com/b1tsblog/license-platform/pkg/licensekit" +) + +const ( + ProductID = "ai-disclosure-standard" + ProductVersion = "1.6.1" + FeatureCustomText = "custom_text" + FeatureCustomBadge = "custom_badge" + FeatureWhiteLabel = "white_label" +) + +//go:embed trusted_keys.json +var embeddedTrustStore []byte + +func productTrustStore() (licensekit.TrustStore, error) { + store, err := licensekit.ParseTrustStore(embeddedTrustStore) + if err != nil { + return licensekit.TrustStore{}, fmt.Errorf("embedded license trust store: %w", err) + } + return store, nil +} diff --git a/internal/app/metrics.go b/internal/app/metrics.go new file mode 100644 index 0000000..b672478 --- /dev/null +++ b/internal/app/metrics.go @@ -0,0 +1,42 @@ +package app + +import ( + "fmt" + "net/http" + "sync/atomic" + "time" +) + +type metrics struct { + startedAt time.Time + requests atomic.Uint64 + badgeRenders atomic.Uint64 + validationRequests atomic.Uint64 + validationFailures atomic.Uint64 + panics atomic.Uint64 +} + +func newMetrics() *metrics { return &metrics{startedAt: time.Now()} } + +func (m *metrics) serveHTTP(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + fmt.Fprintf(w, "# HELP ai_disclosure_uptime_seconds Process uptime in seconds.\n") + fmt.Fprintf(w, "# TYPE ai_disclosure_uptime_seconds gauge\n") + fmt.Fprintf(w, "ai_disclosure_uptime_seconds %.0f\n", time.Since(m.startedAt).Seconds()) + fmt.Fprintf(w, "# HELP ai_disclosure_http_requests_total Total HTTP requests.\n") + fmt.Fprintf(w, "# TYPE ai_disclosure_http_requests_total counter\n") + fmt.Fprintf(w, "ai_disclosure_http_requests_total %d\n", m.requests.Load()) + fmt.Fprintf(w, "# HELP ai_disclosure_badge_renders_total Total rendered SVG badges.\n") + fmt.Fprintf(w, "# TYPE ai_disclosure_badge_renders_total counter\n") + fmt.Fprintf(w, "ai_disclosure_badge_renders_total %d\n", m.badgeRenders.Load()) + fmt.Fprintf(w, "# HELP ai_disclosure_validation_requests_total Total validation requests.\n") + fmt.Fprintf(w, "# TYPE ai_disclosure_validation_requests_total counter\n") + fmt.Fprintf(w, "ai_disclosure_validation_requests_total %d\n", m.validationRequests.Load()) + fmt.Fprintf(w, "# HELP ai_disclosure_validation_failures_total Failed validation requests.\n") + fmt.Fprintf(w, "# TYPE ai_disclosure_validation_failures_total counter\n") + fmt.Fprintf(w, "ai_disclosure_validation_failures_total %d\n", m.validationFailures.Load()) + fmt.Fprintf(w, "# HELP ai_disclosure_panics_total Recovered handler panics.\n") + fmt.Fprintf(w, "# TYPE ai_disclosure_panics_total counter\n") + fmt.Fprintf(w, "ai_disclosure_panics_total %d\n", m.panics.Load()) +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..f460e52 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,988 @@ +package app + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "io/fs" + "log/slog" + "net/http" + "net/url" + "runtime/debug" + "sort" + "strings" + "time" + + "github.com/b1tsblog/ai-disclosure-standard/internal/badge" + "github.com/b1tsblog/ai-disclosure-standard/internal/declaration" + "github.com/b1tsblog/ai-disclosure-standard/internal/i18n" + "github.com/b1tsblog/ai-disclosure-standard/internal/marketing" + webassets "github.com/b1tsblog/ai-disclosure-standard/web" + "github.com/b1tsblog/license-platform/sdk/go/licenseclient" +) + +type Server struct { + cfg Config + logger *slog.Logger + templates *template.Template + metrics *metrics + licenses *licenseclient.Client + mux *http.ServeMux +} + +type option struct{ Value, Label string } +type fact struct{ Label, Value, Link string } +type componentRow struct{ Name, Extent, Activities, Review, Note string } +type languageLink struct { + Code, Name, URL, AbsoluteURL string + Current bool +} + +type pageData struct { + Name string + BaseURL string + ContactURL string + Lang string + Text map[string]string + Languages []i18n.LanguageOption + Presets []option + Components []option + Extents []option + Reviews []option + Assurances []option + Declaration declaration.Declaration + Title string + Description string + BadgeURL string + ManifestURL string + CanonicalURL string + JSONLD template.JS + AppConfig template.JS + Facts []fact + ComponentRows []componentRow + Summary []string + IsArticle bool + License licenseclient.Status + CustomText bool + CustomBadge bool + Marketing marketing.Page + LanguageLinks []languageLink + DefaultLanguageURL string +} + +type clientConfig struct { + BaseURL string `json:"baseURL"` + SelectedLanguage string `json:"selectedLanguage"` + Locales map[string]i18n.Locale `json:"locales"` + Capabilities map[string]bool `json:"capabilities"` +} + +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 != nil { + return nil, fmt.Errorf("parse templates: %w", err) + } + trustStore, err := productTrustStore() + if err != nil { + return nil, err + } + licenses := licenseclient.New(ctx, licenseclient.Config{ + Product: ProductID, Token: cfg.LicenseToken, TrustStore: trustStore, BaseURL: cfg.BaseURL, + InstanceID: cfg.LicenseInstanceID, Mode: cfg.LicenseMode, ServerURL: cfg.LicenseServerURL, + CacheFile: cfg.LicenseCacheFile, RefreshEvery: cfg.LicenseRefreshEvery, + RequestTimeout: cfg.LicenseTimeout, ClientVersion: ProductVersion, + }) + licenses.Start(ctx) + s := &Server{cfg: cfg, logger: logger, templates: tmpl, metrics: newMetrics(), licenses: licenses, mux: http.NewServeMux()} + s.routes() + return s.middleware(s.mux), nil +} + +func (s *Server) routes() { + staticFS, _ := fs.Sub(webassets.Files, "static") + fileServer := http.FileServer(http.FS(staticFS)) + s.mux.HandleFunc("GET /", s.handleIndex) + s.mux.HandleFunc("GET /product", s.handleMarketing) + s.mux.HandleFunc("GET /install", s.handleMarketingAlias) + s.mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(fileServer))) + s.mux.HandleFunc("GET /badge/{file}", s.handlePresetBadge) + s.mux.HandleFunc("GET /v1/badge.svg", s.handleBadge) + s.mux.HandleFunc("GET /declaration", s.handleDeclaration) + s.mux.HandleFunc("GET /v1/declaration.json", s.handleManifest) + s.mux.HandleFunc("POST /v1/validate", s.handleValidate) + s.mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities) + s.mux.HandleFunc("GET /schema/v1/declaration.schema.json", s.handleSchema) + 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) +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + lang := s.language(r) + locale := i18n.Get(lang) + cfg := clientConfig{ + BaseURL: s.cfg.BaseURL, SelectedLanguage: lang, Locales: i18n.ClientCatalogs(), + Capabilities: map[string]bool{ + FeatureCustomText: s.licenses.Has(FeatureCustomText), + FeatureCustomBadge: s.licenses.Has(FeatureCustomBadge), + }, + } + 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(), Presets: presetOptions(locale), Components: orderedOptions(locale.Components, []string{"text", "coverImage", "image", "audio", "video", "code", "other"}), + Extents: orderedOptions(locale.Extents, []string{"assisted", "none", "partial", "mostly", "full"}), Reviews: orderedOptions(locale.Reviews, []string{"editorial", "expert", "basic", "none"}), + Assurances: orderedOptions(locale.Assurances, []string{"selfDeclared", "technicallyRecorded", "signed", "verified"}), + AppConfig: template.JS(appJSON), License: s.licenses.Status(), CustomText: s.licenses.Has(FeatureCustomText), CustomBadge: s.licenses.Has(FeatureCustomBadge), + } + s.renderHTML(w, "index.html", data) +} + +func (s *Server) handleMarketing(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/product" { + http.NotFound(w, r) + return + } + lang := s.language(r) + page := marketing.Build(lang, s.cfg.PublicName, s.cfg.BaseURL, s.cfg.SalesURL, s.cfg.ContactURL, marketing.Prices{ + Community: s.cfg.CommunityPrice, + Pro: s.cfg.ProPrice, + Publisher: s.cfg.PublisherPrice, + Agency: s.cfg.AgencyPrice, + }) + data := pageData{ + Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, + Lang: lang, Languages: i18n.Languages(), Marketing: page, License: s.licenses.Status(), + } + s.renderHTML(w, "marketing.html", data) +} + +func (s *Server) handleMarketingAlias(w http.ResponseWriter, r *http.Request) { + target := "/product" + if raw := r.URL.Query().Encode(); raw != "" { + target += "?" + raw + } + if r.URL.Path == "/install" { + target += "#install" + } + http.Redirect(w, r, target, http.StatusTemporaryRedirect) +} + +func (s *Server) handlePresetBadge(w http.ResponseWriter, r *http.Request) { + file := r.PathValue("file") + if !strings.HasSuffix(file, ".svg") { + http.NotFound(w, r) + return + } + presetID := strings.TrimSuffix(file, ".svg") + if _, ok := declaration.Presets[presetID]; !ok { + s.problem(w, http.StatusNotFound, "unknown_preset", "Unknown badge preset.") + return + } + q := cloneValues(r.URL.Query()) + q.Set("preset", presetID) + q.Set("lang", s.languageFromValues(r, q)) + s.renderBadge(w, r, q) +} + +func (s *Server) handleBadge(w http.ResponseWriter, r *http.Request) { + q := cloneValues(r.URL.Query()) + q.Set("lang", s.languageFromValues(r, q)) + s.renderBadge(w, r, q) +} + +func (s *Server) renderBadge(w http.ResponseWriter, r *http.Request, q url.Values) { + if customTextRequested(q, "label", "badgeLabel", "message", "badgeMessage") && !s.licenses.Has(FeatureCustomBadge) { + s.problem(w, http.StatusForbidden, "pro_feature_required", "Custom badge labels require the Pro feature custom_badge.") + return + } + if customTextRequested(q, "leftColor", "rightColor") && !s.licenses.Has(FeatureCustomBadge) { + s.problem(w, http.StatusForbidden, "pro_feature_required", "Custom badge colours require the Pro feature custom_badge.") + return + } + leftColor, rightColor := strings.TrimSpace(q.Get("leftColor")), strings.TrimSpace(q.Get("rightColor")) + if (leftColor != "" && !validHexColor(leftColor)) || (rightColor != "" && !validHexColor(rightColor)) { + s.problem(w, http.StatusBadRequest, "invalid_colour", "Badge colours must use the form #RRGGBB.") + return + } + extent := strings.TrimSpace(q.Get("extent")) + presetID := strings.TrimSpace(q.Get("preset")) + isArticle := q.Get("mode") == "article" + if isArticle { + if d, err := s.declarationFromQuery(q); err == nil { + extent = overallExtent(d) + } + } + if p, ok := declaration.Presets[presetID]; ok && extent == "" { + extent = p.Extent + } + if extent == "" { + extent = "assisted" + } + if !declaration.IsValidExtent(extent) { + s.problem(w, http.StatusBadRequest, "invalid_extent", "extent must be one of none, assisted, partial, mostly or full") + return + } + locale := i18n.Get(q.Get("lang")) + label := locale.Text["ai_label"] + message := locale.Extents[extent] + if preset, ok := locale.Presets[presetID]; ok { + message = preset.Title + } + if isArticle { + message = locale.Text["badge_article"] + // Article-level declarations combine several AI-use states. Use a calm + // violet as the neutral default instead of inheriting the strongest + // component colour (which may be red). Explicit Pro colours still win. + if rightColor == "" { + rightColor = "#7c3aed" + } + } + if v := firstNonEmpty(q.Get("badgeLabel"), q.Get("label")); v != "" { + label = v + } + if v := firstNonEmpty(q.Get("badgeMessage"), q.Get("message")); v != "" { + message = v + } + link := strings.TrimSpace(q.Get("link")) + if link == "auto" { + copy := cloneValues(q) + for _, key := range []string{"link", "label", "message", "style", "theme", "leftColor", "rightColor"} { + copy.Del(key) + } + link = s.cfg.BaseURL + "/declaration?" + copy.Encode() + } + data, etag := badge.Render(badge.Options{Label: label, Message: message, Extent: extent, Style: q.Get("style"), Theme: q.Get("theme"), Link: link, LeftColor: leftColor, RightColor: rightColor}) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + s.metrics.badgeRenders.Add(1) + w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400") + w.Header().Set("ETag", etag) + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") + _, _ = w.Write(data) +} + +func (s *Server) handleDeclaration(w http.ResponseWriter, r *http.Request) { + q := cloneValues(r.URL.Query()) + q.Set("lang", s.languageFromValues(r, q)) + d, err := s.declarationFromQuery(q) + if err != nil { + s.declarationError(w, err) + return + } + locale := i18n.Get(d.Language) + presetText := locale.Presets[q.Get("preset")] + title, description := presetText.Title, presetText.Description + if title == "" { + for _, c := range d.Components { + title = locale.Extents[c.AIExtent] + break + } + description = locale.Text["transparency_text"] + } + if d.Presentation != nil { + if d.Presentation.Title != "" { + title = d.Presentation.Title + } + if d.Presentation.Description != "" { + description = d.Presentation.Description + } + } + canonicalURL := s.cfg.BaseURL + "/declaration?" + q.Encode() + manifestURL := s.cfg.BaseURL + "/v1/declaration.json?" + q.Encode() + badgeQ := cloneValues(q) + badgeQ.Set("link", canonicalURL) + badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode() + jsonLD, _ := json.Marshal(d) + isArticle := q.Get("mode") == "article" || len(d.Components) > 1 + if isArticle && (d.Presentation == nil || d.Presentation.Title == "") { + title = locale.Text["article_title"] + description = articleShortDescription(d, locale) + } + languageLinks, defaultLanguageURL := s.declarationLanguageLinks(q, d.Language) + data := pageData{ + Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: d.Language, Text: locale.Text, Languages: i18n.Languages(), + Declaration: d, Title: title, Description: description, BadgeURL: badgeURL, ManifestURL: manifestURL, CanonicalURL: canonicalURL, JSONLD: template.JS(jsonLD), + Facts: declarationFacts(d, locale), ComponentRows: declarationComponentRows(d, locale), Summary: declarationSummary(d, locale), IsArticle: isArticle, License: s.licenses.Status(), + LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL, + } + w.Header().Set("Link", "<"+manifestURL+">; rel=describedby; type=application/ld+json") + s.renderHTML(w, "declaration.html", data) +} + +func (s *Server) declarationLanguageLinks(q url.Values, current string) ([]languageLink, string) { + links := make([]languageLink, 0, len(i18n.Languages())) + for _, language := range i18n.Languages() { + values := cloneValues(q) + values.Set("lang", language.Code) + relative := "/declaration?" + values.Encode() + links = append(links, languageLink{ + Code: language.Code, Name: language.Name, URL: relative, + AbsoluteURL: s.cfg.BaseURL + relative, Current: language.Code == current, + }) + } + defaultValues := cloneValues(q) + defaultValues.Del("lang") + defaultURL := s.cfg.BaseURL + "/declaration" + if encoded := defaultValues.Encode(); encoded != "" { + defaultURL += "?" + encoded + } + return links, defaultURL +} + +func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { + q := cloneValues(r.URL.Query()) + q.Set("lang", s.languageFromValues(r, q)) + d, err := s.declarationFromQuery(q) + if err != nil { + s.declarationError(w, err) + return + } + w.Header().Set("Content-Type", "application/ld+json; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400") + w.Header().Set("Access-Control-Allow-Origin", "*") + _ = json.NewEncoder(w).Encode(d) +} + +func (s *Server) declarationFromQuery(q url.Values) (declaration.Declaration, error) { + return declaration.NewFromQueryWithOptions(q, s.cfg.BaseURL+"/context/v1", declaration.ParseOptions{ + AllowCustomText: s.licenses.Has(FeatureCustomText), AllowCustomBadge: s.licenses.Has(FeatureCustomBadge), DefaultLanguage: s.cfg.DefaultLanguage, + }) +} + +func (s *Server) declarationError(w http.ResponseWriter, err error) { + if errors.Is(err, declaration.ErrCustomTextRequiresPro) || errors.Is(err, declaration.ErrCustomBadgeRequiresPro) { + s.problem(w, http.StatusForbidden, "pro_feature_required", err.Error()) + return + } + s.problem(w, http.StatusBadRequest, "invalid_declaration", err.Error()) +} + +func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) { + s.metrics.validationRequests.Add(1) + body := http.MaxBytesReader(w, r.Body, 1<<20) + defer body.Close() + dec := json.NewDecoder(body) + dec.DisallowUnknownFields() + var d declaration.Declaration + if err := dec.Decode(&d); err != nil { + s.metrics.validationFailures.Add(1) + s.problem(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + if err := ensureEOF(dec); err != nil { + s.metrics.validationFailures.Add(1) + s.problem(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + if err := declaration.Validate(d); err != nil { + s.metrics.validationFailures.Add(1) + s.problem(w, http.StatusUnprocessableEntity, "validation_failed", err.Error()) + return + } + s.writeJSON(w, http.StatusOK, map[string]any{"valid": true, "schemaVersion": d.SchemaVersion}) +} + +func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) { + s.writeJSON(w, http.StatusOK, map[string]any{"license": s.licenses.Status(), "supportedLanguages": languageCodes()}) +} + +func (s *Server) handleSchema(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/schema+json; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Access-Control-Allow-Origin", "*") + _, _ = w.Write([]byte(strings.ReplaceAll(declarationSchema, "__BASE_URL__", s.cfg.BaseURL))) +} + +func (s *Server) handleContext(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/ld+json; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Access-Control-Allow-Origin", "*") + _, _ = w.Write([]byte(strings.ReplaceAll(jsonLDContext, "__BASE_URL__", s.cfg.BaseURL))) +} + +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") + _, _ = io.WriteString(w, "ok\n") +} +func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = io.WriteString(w, "ready\n") +} + +func (s *Server) renderHTML(w http.ResponseWriter, name string, data pageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err := s.templates.ExecuteTemplate(w, name, data); err != nil { + s.logger.Error("template render failed", "template", name, "error", err) + } +} + +func (s *Server) language(r *http.Request) string { + return i18n.Resolve(r.URL.Query().Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage) +} +func (s *Server) languageFromValues(r *http.Request, values url.Values) string { + return i18n.Resolve(values.Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage) +} + +func (s *Server) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + s.metrics.requests.Add(1) + requestID := r.Header.Get("X-Request-ID") + if requestID == "" { + requestID = randomID() + } + w.Header().Set("X-Request-ID", requestID) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin") + if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/schema/") || strings.HasPrefix(r.URL.Path, "/context/") { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + } + rw := &responseWriter{ResponseWriter: w, status: http.StatusOK} + defer func() { + if recovered := recover(); recovered != nil { + s.metrics.panics.Add(1) + 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)) + }() + next.ServeHTTP(rw, r) + }) +} + +func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) { + w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"type": s.cfg.BaseURL + "/problems/" + code, "title": http.StatusText(status), "status": status, "code": code, "detail": detail}) +} + +func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +type responseWriter struct { + http.ResponseWriter + status, bytes int +} + +func (w *responseWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} +func (w *responseWriter) Write(b []byte) (int, error) { + n, err := w.ResponseWriter.Write(b) + w.bytes += n + return n, err +} + +func cacheStatic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=3600") + next.ServeHTTP(w, r) + }) +} + +func presetOptions(locale i18n.Locale) []option { + ids := []string{"research", "no-ai", "summary", "full"} + out := make([]option, 0, len(ids)) + for _, id := range ids { + out = append(out, option{Value: id, Label: locale.Presets[id].Title}) + } + return out +} + +func orderedOptions(values map[string]string, order []string) []option { + out := make([]option, 0, len(order)) + for _, key := range order { + out = append(out, option{Value: key, Label: values[key]}) + } + return out +} + +func declarationFacts(d declaration.Declaration, locale i18n.Locale) []fact { + facts := []fact{{Label: locale.Text["fact_assurance"], Value: locale.Assurances[d.Assurance]}} + if d.Subject != "" { + facts = append(facts, fact{Label: locale.Text["fact_subject"], Value: d.Subject, Link: d.Subject}) + } + if d.EditorialResponsibility != nil && d.EditorialResponsibility.Name != "" { + facts = append(facts, fact{Label: locale.Text["fact_responsibility"], Value: d.EditorialResponsibility.Name, Link: d.EditorialResponsibility.URL}) + } + if d.DeclaredAt != "" { + facts = append(facts, fact{Label: locale.Text["fact_declared_at"], Value: d.DeclaredAt}) + } + return facts +} + +func declarationComponentRows(d declaration.Declaration, locale i18n.Locale) []componentRow { + order := []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code", "other"} + rows := make([]componentRow, 0, len(d.Components)) + for _, name := range order { + component, ok := d.Components[name] + if !ok { + continue + } + activities := make([]string, 0, len(component.Activities)) + for _, activity := range component.Activities { + if translated := locale.Activities[activity]; translated != "" { + activities = append(activities, translated) + } else { + activities = append(activities, activity) + } + } + activityText := locale.Text["not_specified"] + if component.AIExtent == "none" { + activityText = locale.Text["not_applicable"] + } + if activityText == "" { + activityText = locale.Text["none_value"] + } + if len(activities) > 0 { + activityText = strings.Join(activities, ", ") + } + rows = append(rows, componentRow{Name: locale.Components[name], Extent: locale.Extents[component.AIExtent], Activities: activityText, Review: locale.Reviews[component.HumanReview], Note: component.Note}) + } + return rows +} + +func overallExtent(d declaration.Declaration) string { + rank := map[string]int{"none": 0, "assisted": 1, "partial": 2, "mostly": 3, "full": 4} + best, bestRank := "none", 0 + for _, c := range d.Components { + if rank[c.AIExtent] > bestRank { + best, bestRank = c.AIExtent, rank[c.AIExtent] + } + } + return best +} + +func articleShortDescription(d declaration.Declaration, locale i18n.Locale) string { + rows := declarationComponentRows(d, locale) + parts := make([]string, 0, len(rows)) + for _, row := range rows { + parts = append(parts, row.Name+": "+row.Extent) + } + return strings.Join(parts, " · ") +} + +func declarationSummary(d declaration.Declaration, locale i18n.Locale) []string { + if len(d.Components) == 0 { + return nil + } + intro := map[string]string{ + "de": "Diese Erklärung dokumentiert nachvollziehbar, in welchen Bereichen künstliche Intelligenz bei der Erstellung und Veröffentlichung des gekennzeichneten Inhalts eingesetzt wurde.", + "en": "This declaration documents, in a transparent and traceable form, where artificial intelligence was used in creating and publishing the labelled content.", + "fr": "Cette déclaration documente de manière transparente et traçable les domaines dans lesquels l’intelligence artificielle a été utilisée pour créer et publier le contenu identifié.", + "es": "Esta declaración documenta de forma transparente y trazable en qué áreas se utilizó inteligencia artificial para crear y publicar el contenido identificado.", + "it": "La presente dichiarazione documenta in modo trasparente e tracciabile gli ambiti in cui l’intelligenza artificiale è stata utilizzata per creare e pubblicare il contenuto indicato.", + "nl": "Deze verklaring documenteert op transparante en navolgbare wijze op welke onderdelen kunstmatige intelligentie is gebruikt bij het maken en publiceren van de aangeduide inhoud.", + "pt": "Esta declaração documenta, de forma transparente e rastreável, as áreas em que a inteligência artificial foi utilizada na criação e publicação do conteúdo identificado.", + "pl": "Niniejsza deklaracja w przejrzysty i możliwy do prześledzenia sposób dokumentuje obszary, w których wykorzystano sztuczną inteligencję podczas tworzenia i publikacji oznaczonej treści.", + } + lang := locale.Code + if intro[lang] == "" { + lang = "en" + } + statements := []string{intro[lang]} + for _, name := range []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code", "other"} { + component, ok := d.Components[name] + if !ok { + continue + } + statements = append(statements, componentSummaryStatement(locale, name, component)) + } + closing := map[string][]string{ + "de": { + "Sämtliche Angaben werden unter einem gemeinsamen Link veröffentlicht und stehen zusätzlich als maschinenlesbares JSON-LD zur Verfügung.", + "Die inhaltliche und redaktionelle Verantwortung verbleibt unabhängig vom ausgewiesenen KI-Anteil bei der veröffentlichenden Person oder Organisation.", + }, + "en": { + "All information is published under a single link and is also available as machine-readable JSON-LD.", + "Responsibility for the content and its editorial publication remains with the publishing person or organisation, irrespective of the stated level of AI involvement.", + }, + "fr": { + "Toutes les informations sont publiées sous un lien unique et sont également disponibles au format JSON-LD lisible par machine.", + "La responsabilité du contenu et de sa publication éditoriale demeure celle de la personne ou de l’organisation éditrice, indépendamment du niveau d’intervention de l’IA indiqué.", + }, + "es": { + "Toda la información se publica mediante un único enlace y también está disponible como JSON-LD legible por máquina.", + "La responsabilidad sobre el contenido y su publicación editorial permanece en la persona u organización editora, con independencia del nivel de intervención de la IA indicado.", + }, + "it": { + "Tutte le informazioni sono pubblicate tramite un unico collegamento e sono disponibili anche come JSON-LD leggibile automaticamente.", + "La responsabilità del contenuto e della sua pubblicazione editoriale resta in capo alla persona o all’organizzazione che lo pubblica, indipendentemente dal livello dichiarato di intervento dell’IA.", + }, + "nl": { + "Alle gegevens worden via één gezamenlijke link gepubliceerd en zijn daarnaast beschikbaar als machineleesbare JSON-LD.", + "De inhoudelijke en redactionele verantwoordelijkheid blijft, ongeacht de vermelde mate van AI-gebruik, bij de publicerende persoon of organisatie.", + }, + "pt": { + "Todas as informações são publicadas através de uma única ligação e estão igualmente disponíveis como JSON-LD legível por máquina.", + "A responsabilidade pelo conteúdo e pela sua publicação editorial permanece com a pessoa ou organização responsável pela publicação, independentemente do grau de utilização de IA indicado.", + }, + "pl": { + "Wszystkie informacje są publikowane pod jednym wspólnym odsyłaczem i są również dostępne jako maszynowo czytelny JSON-LD.", + "Odpowiedzialność za treść i jej redakcyjną publikację pozostaje po stronie publikującej osoby lub organizacji, niezależnie od wskazanego zakresu użycia AI.", + }, + } + end := closing[lang] + end = append(end, assuranceStatement(d.Assurance, locale)) + return []string{strings.Join(statements, " "), strings.Join(end, " ")} +} + +func componentSummaryStatement(locale i18n.Locale, name string, component declaration.Component) string { + componentName := locale.Components[name] + if componentName == "" { + componentName = name + } + extent := locale.Extents[component.AIExtent] + activities := make([]string, 0, len(component.Activities)) + for _, activity := range component.Activities { + if value := locale.Activities[activity]; value != "" { + activities = append(activities, value) + } else { + activities = append(activities, activity) + } + } + activityText := strings.Join(activities, ", ") + review := locale.Reviews[component.HumanReview] + + switch locale.Code { + case "de": + if component.AIExtent == "none" { + if component.HumanReview == "none" { + return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt.", componentName) + } + return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt; ergänzend ist eine %s menschliche Prüfung dokumentiert.", componentName, germanReviewAdjective(component.HumanReview)) + } + purpose := "" + if activityText != "" { + purpose = " für " + activityText + } + if component.HumanReview == "none" { + return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; eine menschliche Prüfung der KI-bezogenen Ergebnisse ist nicht angegeben.", componentName, germanExtentPhrase(component.AIExtent), purpose) + } + return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; die KI-bezogenen Ergebnisse wurden %s geprüft.", componentName, germanExtentPhrase(component.AIExtent), purpose, germanReviewAdverb(component.HumanReview)) + case "en": + if component.AIExtent == "none" { + if component.HumanReview == "none" { + return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName) + } + return fmt.Sprintf("According to the information provided, no AI was used for “%s”; a %s human review is nevertheless recorded.", componentName, strings.ToLower(review)) + } + purpose := "" + if activityText != "" { + purpose = " for " + activityText + } + if component.HumanReview == "none" { + return fmt.Sprintf("For “%s”, %s%s is recorded; no human review of the AI-related output is specified.", componentName, strings.ToLower(extent), purpose) + } + return fmt.Sprintf("For “%s”, %s%s is recorded; the AI-related output underwent %s human review.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) + default: + if component.AIExtent == "none" { + return genericNoAIStatement(locale.Code, componentName, component.HumanReview, review) + } + return genericAIStatement(locale.Code, componentName, extent, activityText, component.HumanReview, review) + } +} + +func germanExtentPhrase(extent string) string { + switch extent { + case "assisted": + return "eine KI-unterstützte Nutzung" + case "partial": + return "eine teilweise KI-generierte Erstellung" + case "mostly": + return "eine überwiegend KI-generierte Erstellung" + case "full": + return "eine vollständig KI-generierte Erstellung" + default: + return "eine KI-Nutzung" + } +} + +func germanReviewAdverb(review string) string { + switch review { + case "basic": + return "grundlegend" + case "editorial": + return "redaktionell" + case "expert": + return "fachlich" + default: + return "menschlich" + } +} + +func germanReviewAdjective(review string) string { + switch review { + case "basic": + return "grundlegende" + case "editorial": + return "redaktionelle" + case "expert": + return "fachliche" + default: + return "menschliche" + } +} + +func genericNoAIStatement(lang, componentName, reviewCode, review string) string { + withReview := reviewCode != "none" + switch lang { + case "fr": + if withReview { + return fmt.Sprintf("Selon les informations fournies, aucune IA n’a été utilisée pour « %s » ; une vérification humaine %s est néanmoins documentée.", componentName, strings.ToLower(review)) + } + return fmt.Sprintf("Selon les informations fournies, aucune IA n’a été utilisée pour « %s ».", componentName) + case "es": + if withReview { + return fmt.Sprintf("Según la información proporcionada, no se utilizó IA en «%s»; no obstante, se documenta una revisión humana %s.", componentName, strings.ToLower(review)) + } + return fmt.Sprintf("Según la información proporcionada, no se utilizó IA en «%s».", componentName) + case "it": + if withReview { + return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata l’IA; è comunque documentata una revisione umana %s.", componentName, strings.ToLower(review)) + } + return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata l’IA.", componentName) + case "nl": + if withReview { + return fmt.Sprintf("Volgens de verstrekte informatie is voor ‘%s’ geen AI gebruikt; er is wel een %s menselijke controle vastgelegd.", componentName, strings.ToLower(review)) + } + return fmt.Sprintf("Volgens de verstrekte informatie is voor ‘%s’ geen AI gebruikt.", componentName) + case "pt": + if withReview { + return fmt.Sprintf("De acordo com as informações fornecidas, não foi utilizada IA em «%s»; está, ainda assim, documentada uma revisão humana %s.", componentName, strings.ToLower(review)) + } + return fmt.Sprintf("De acordo com as informações fornecidas, não foi utilizada IA em «%s».", componentName) + case "pl": + if withReview { + return fmt.Sprintf("Zgodnie z podanymi informacjami w obszarze „%s” nie użyto AI; udokumentowano jednak weryfikację człowieka na poziomie %s.", componentName, review) + } + return fmt.Sprintf("Zgodnie z podanymi informacjami w obszarze „%s” nie użyto AI.", componentName) + default: + return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName) + } +} + +func genericAIStatement(lang, componentName, extent, activities string, reviewCode, review string) string { + purpose := activities + if purpose == "" { + purpose = "—" + } + switch lang { + case "fr": + if reviewCode == "none" { + return fmt.Sprintf("Pour « %s », l’utilisation déclarée est %s, avec pour finalité %s ; aucune vérification humaine des résultats liés à l’IA n’est indiquée.", componentName, strings.ToLower(extent), purpose) + } + return fmt.Sprintf("Pour « %s », l’utilisation déclarée est %s, avec pour finalité %s ; les résultats liés à l’IA ont fait l’objet d’une vérification humaine %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) + case "es": + if reviewCode == "none" { + return fmt.Sprintf("Para «%s» se declara %s, con la finalidad %s; no se indica una revisión humana de los resultados relacionados con la IA.", componentName, strings.ToLower(extent), purpose) + } + return fmt.Sprintf("Para «%s» se declara %s, con la finalidad %s; los resultados relacionados con la IA fueron objeto de una revisión humana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) + case "it": + if reviewCode == "none" { + return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; non è indicata una revisione umana dei risultati legati all’IA.", componentName, strings.ToLower(extent), purpose) + } + return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; i risultati legati all’IA sono stati sottoposti a revisione umana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) + case "nl": + if reviewCode == "none" { + return fmt.Sprintf("Voor ‘%s’ is %s vastgelegd, met als doel %s; er is geen menselijke controle van de AI-gerelateerde resultaten aangegeven.", componentName, strings.ToLower(extent), purpose) + } + return fmt.Sprintf("Voor ‘%s’ is %s vastgelegd, met als doel %s; de AI-gerelateerde resultaten hebben een %s menselijke controle ondergaan.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) + case "pt": + if reviewCode == "none" { + return fmt.Sprintf("Para «%s» está documentado %s, com a finalidade %s; não é indicada uma revisão humana dos resultados relacionados com IA.", componentName, strings.ToLower(extent), purpose) + } + return fmt.Sprintf("Para «%s» está documentado %s, com a finalidade %s; os resultados relacionados com IA foram sujeitos a uma revisão humana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) + case "pl": + if reviewCode == "none" { + return fmt.Sprintf("Dla obszaru „%s” udokumentowano %s w celu %s; nie wskazano weryfikacji człowieka dla wyników związanych z AI.", componentName, extent, purpose) + } + return fmt.Sprintf("Dla obszaru „%s” udokumentowano %s w celu %s; wyniki związane z AI poddano weryfikacji człowieka na poziomie %s.", componentName, extent, purpose, review) + default: + return fmt.Sprintf("For “%s”, %s is recorded.", componentName, strings.ToLower(extent)) + } +} + +func assuranceStatement(assurance string, locale i18n.Locale) string { + key := "assurance_" + assurance + "_statement" + if value := locale.Text[key]; value != "" { + if assurance == "signed" { + switch locale.Code { + case "de": + return value + " Die Signatur bestätigt nicht automatisch die inhaltliche Richtigkeit der Angaben." + case "en": + return value + " The signature does not by itself confirm the substantive accuracy of the information." + } + } + return value + } + if value := locale.Text["assurance_"+assurance+"_description"]; value != "" { + return value + } + return locale.Assurances[assurance] +} + +func customTextRequested(q url.Values, keys ...string) bool { + for _, key := range keys { + if strings.TrimSpace(q.Get(key)) != "" { + return true + } + } + return false +} +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} +func validHexColor(value string) bool { + if len(value) != 7 || value[0] != '#' { + return false + } + for _, r := range value[1:] { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { + return false + } + } + return true +} +func languageCodes() []string { + languages := i18n.Languages() + out := make([]string, 0, len(languages)) + for _, l := range languages { + out = append(out, l.Code) + } + sort.Strings(out) + return out +} +func cloneValues(in url.Values) url.Values { + out := url.Values{} + for k, v := range in { + out[k] = append([]string(nil), v...) + } + return out +} +func randomID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} +func clientIP(r *http.Request, trustProxy bool) string { + if trustProxy { + if x := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); x != "" { + return x + } + } + return r.RemoteAddr +} +func ensureEOF(dec *json.Decoder) error { + var extra any + err := dec.Decode(&extra) + if errors.Is(err, io.EOF) { + return nil + } + if err == nil { + return errors.New("request body must contain exactly one JSON value") + } + return err +} + +const declarationSchema = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "__BASE_URL__/schema/v1/declaration.schema.json", + "title": "AI Usage Declaration", + "type": "object", + "additionalProperties": false, + "required": ["@context", "@type", "schemaVersion", "language", "components", "assurance"], + "properties": { + "@context": {"type": "string"}, + "@type": {"const": "AIUsageDeclaration"}, + "schemaVersion": {"const": "1.1"}, + "subject": {"type": "string", "format": "uri"}, + "declaredAt": {"type": "string", "format": "date-time"}, + "language": {"enum": ["de", "en", "fr", "es", "it", "nl", "pt", "pl"]}, + "assurance": {"enum": ["selfDeclared", "technicallyRecorded", "signed", "verified"]}, + "editorialResponsibility": { + "type": "object", "additionalProperties": false, + "properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}} + }, + "presentation": { + "type": "object", "additionalProperties": false, + "properties": { + "title": {"type": "string", "maxLength": 120}, + "description": {"type": "string", "maxLength": 500}, + "badgeLabel": {"type": "string", "maxLength": 40}, + "badgeMessage": {"type": "string", "maxLength": 80}, + "leftColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}, + "rightColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"} + } + }, + "components": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/component"}} + }, + "$defs": { + "component": { + "type": "object", "additionalProperties": false, + "required": ["aiExtent", "humanReview"], + "properties": { + "aiExtent": {"enum": ["none", "assisted", "partial", "mostly", "full"]}, + "activities": {"type": "array", "uniqueItems": true, "items": {"enum": ["research", "summarisation", "drafting", "generation", "translation", "editing", "imageGeneration", "codeGeneration", "transcription", "classification"]}}, + "humanReview": {"enum": ["none", "basic", "editorial", "expert"]}, + "note": {"type": "string", "maxLength": 500} + } + } + } +}` + +const jsonLDContext = `{ + "@context": { + "@version": 1.1, + "AIUsageDeclaration": "__BASE_URL__/vocab/AIUsageDeclaration", + "schemaVersion": "__BASE_URL__/vocab/schemaVersion", + "subject": {"@id": "https://schema.org/about", "@type": "@id"}, + "declaredAt": {"@id": "https://schema.org/dateCreated", "@type": "https://www.w3.org/2001/XMLSchema#dateTime"}, + "language": "https://schema.org/inLanguage", + "components": "__BASE_URL__/vocab/components", + "aiExtent": "__BASE_URL__/vocab/aiExtent", + "activities": "__BASE_URL__/vocab/activities", + "humanReview": "__BASE_URL__/vocab/humanReview", + "assurance": "__BASE_URL__/vocab/assurance", + "presentation": "__BASE_URL__/vocab/presentation", + "editorialResponsibility": "https://schema.org/accountablePerson" + } +}` diff --git a/internal/app/server_test.go b/internal/app/server_test.go new file mode 100644 index 0000000..b1fac6a --- /dev/null +++ b/internal/app/server_test.go @@ -0,0 +1,223 @@ +package app + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testHandler(t *testing.T) http.Handler { + t.Helper() + return testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de"}) +} + +func testHandlerConfig(t *testing.T, cfg Config) http.Handler { + t.Helper() + h, err := New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + return h +} + +func TestPresetBadgeFrench(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/badge/research.svg?lang=fr", nil) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") { + t.Fatalf("content type %q", ct) + } + if !strings.Contains(w.Body.String(), "Aide à la recherche") { + t.Fatal("missing localized preset") + } +} + +func TestValidate(t *testing.T) { + body := `{"@context":"https://example.org/context/v1","@type":"AIUsageDeclaration","schemaVersion":"1.1","language":"de","components":{"text":{"aiExtent":"assisted","activities":["research"],"humanReview":"editorial"}},"assurance":"selfDeclared"}` + r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(body)) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var result map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result["valid"] != true { + t.Fatalf("unexpected response: %#v", result) + } +} + +func TestCommunityRejectsCustomBadge(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?lang=en&badgeMessage=Custom", nil) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "pro_feature_required") { + t.Fatalf("unexpected response: %s", w.Body.String()) + } +} + +func TestCapabilities(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/capabilities", nil) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if !strings.Contains(w.Body.String(), `"edition":"community"`) { + t.Fatalf("unexpected response: %s", w.Body.String()) + } +} + +func TestMarketingPageGerman(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/product?lang=de", nil) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + for _, expected := range []string{"KI-Nutzung transparent kennzeichnen", "Docker Compose", "JSON-LD"} { + if !strings.Contains(body, expected) { + t.Fatalf("marketing page missing %q", expected) + } + } +} + +func TestMarketingPageLanguageNegotiation(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/product", nil) + r.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8") + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "Déclarez l’usage de l’IA") { + t.Fatal("missing French marketing content") + } +} + +func TestInstallAlias(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/install?lang=en", nil) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusTemporaryRedirect { + t.Fatalf("status %d", w.Code) + } + if location := w.Header().Get("Location"); location != "/product?lang=en#install" { + t.Fatalf("unexpected redirect %q", location) + } +} + +func TestPricingRouteRemoved(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/pricing", nil) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("status %d", w.Code) + } +} + +func TestDeclarationLanguageSwitcherPreservesQuery(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&textExtent=partial&textReview=expert&coverImageExtent=partial&coverImageReview=editorial&lang=de&assurance=selfDeclared", nil) + w := httptest.NewRecorder() + testHandler(t).ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + for _, expected := range []string{ + `id="declaration-language"`, + `lang=en`, + `textExtent=partial`, + `textReview=expert`, + `coverImageExtent=partial`, + `hreflang="en"`, + `hreflang="x-default"`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("language switcher missing %q", expected) + } + } + if !strings.Contains(body, `