Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c212155f61 | |||
| 33e971bab7 | |||
| 3abd0d2ebc | |||
| 7dbc1c8552 | |||
| 981615f677 | |||
| 30ba365b39 | |||
| 6e152a5121 | |||
| 0a3700cd8b | |||
| b8d3a6092a | |||
| 881d93ddd9 | |||
| 4e2c648ede | |||
| ec58fc30f8 | |||
| 4439684e41 | |||
| db817eabe4 | |||
| 13efcfb986 | |||
| 95c47ee4a7 |
123
.env.example
123
.env.example
@@ -1,65 +1,128 @@
|
||||
# Public origin only: scheme + host, no path/query/fragment.
|
||||
BASE_URL=https://ai.trustednet.eu
|
||||
PUBLIC_NAME=AI Usage Disclosure
|
||||
CONTACT_URL=https://ai.trustednet.eu
|
||||
SALES_URL=https://ai.trustednet.eu/product
|
||||
# =============================================================================
|
||||
# AI Usage Disclosure Standard - complete runtime configuration reference
|
||||
# =============================================================================
|
||||
# Copy to .env and adjust for your deployment. Values ending in *_FILE are
|
||||
# recommended for Docker/Kubernetes secrets. If a *_FILE value is configured,
|
||||
# the file is authoritative and the corresponding plain environment value is
|
||||
# ignored.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# HTTP / public identity
|
||||
# -----------------------------------------------------------------------------
|
||||
LISTEN_ADDRESS=:8080
|
||||
BASE_URL=http://localhost:8080
|
||||
OUTPUT_BASE_URL=http://localhost:8080
|
||||
PUBLIC_NAME=UCNG
|
||||
CONTACT_URL=https://b1tsblog.org/page/ai
|
||||
DEFAULT_LANGUAGE=de
|
||||
|
||||
# Proxy and logging. Enable proxy headers only with explicit trusted CIDRs.
|
||||
TRUST_PROXY=true
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
LOG_CLIENT_IP=false
|
||||
ENABLE_HSTS=true
|
||||
# Used only by the binary's "--healthcheck" command inside containers.
|
||||
HEALTHCHECK_URL=http://127.0.0.1:8080/healthz
|
||||
|
||||
# Prometheus is disabled by default. Enabling it requires a long bearer token;
|
||||
# METRICS_TOKEN_FILE is also supported. Keep the endpoint on an internal network.
|
||||
# Runtime mode: full (UI + API), api (machine APIs only), bulk (Bulk UI + API).
|
||||
SERVICE_MODE=full
|
||||
|
||||
# CORS for /v1, /schema and /context endpoints. Empty disables CORS headers.
|
||||
API_ALLOWED_ORIGIN=*
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Reverse proxy, security and request logging
|
||||
# -----------------------------------------------------------------------------
|
||||
# Proxy headers are trusted only when the direct peer matches one of these
|
||||
# CIDRs. TRUST_PROXY=true without a valid CIDR list is rejected at startup.
|
||||
TRUST_PROXY=false
|
||||
TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
|
||||
# Client IP addresses are omitted from structured request logs by default.
|
||||
LOG_CLIENT_IP=false
|
||||
|
||||
# Enable only when the public service is served exclusively over HTTPS.
|
||||
ENABLE_HSTS=false
|
||||
|
||||
# A commercial deployment can fail closed when no valid runtime licence exists.
|
||||
REQUIRE_LICENSE=false
|
||||
WHITE_LABEL=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Prometheus
|
||||
# -----------------------------------------------------------------------------
|
||||
# /metrics returns 404 while disabled. When enabled, a bearer token of at least
|
||||
# 32 characters is mandatory. Prefer METRICS_TOKEN_FILE in containers.
|
||||
METRICS_ENABLED=false
|
||||
METRICS_TOKEN=
|
||||
METRICS_TOKEN_FILE=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legal operator data. Fill with accurate information before public operation.
|
||||
# LEGAL_STRICT=true prevents startup while core legal/privacy fields are empty
|
||||
# or contain REPLACE_ME/CHANGEME/TODO.
|
||||
# ---------------------------------------------------------------------------
|
||||
LEGAL_STRICT=true
|
||||
LEGAL_NAME=REPLACE_ME
|
||||
LEGAL_ADDRESS=REPLACE_ME
|
||||
# -----------------------------------------------------------------------------
|
||||
# Operator / legal / privacy information
|
||||
# -----------------------------------------------------------------------------
|
||||
# The application exposes /legal, /privacy and /accessibility in full and bulk
|
||||
# mode. These pages are configurable operator information, not legal advice.
|
||||
#
|
||||
# LEGAL_STRICT=true prevents startup while the core operator/privacy fields are
|
||||
# empty or contain REPLACE_ME, CHANGEME or TODO. For local development leave it
|
||||
# false; set it to true after completing the production information.
|
||||
LEGAL_STRICT=false
|
||||
|
||||
LEGAL_NAME=
|
||||
LEGAL_ADDRESS=
|
||||
LEGAL_REPRESENTATIVE=
|
||||
LEGAL_EMAIL=REPLACE_ME
|
||||
LEGAL_EMAIL=
|
||||
LEGAL_PHONE=
|
||||
LEGAL_REGISTER=
|
||||
LEGAL_REGISTER_NUMBER=
|
||||
LEGAL_VAT_ID=
|
||||
|
||||
# Only set these if the service contains journalistic-editorial content and
|
||||
# § 18(2) MStV applies.
|
||||
# Only set these after assessing whether a journalistically/editorially designed
|
||||
# service requires a specifically named responsible person in your jurisdiction.
|
||||
EDITORIAL_RESPONSIBLE_NAME=
|
||||
EDITORIAL_RESPONSIBLE_ADDRESS=
|
||||
|
||||
DATA_PROTECTION_CONTACT=
|
||||
HOSTING_PROVIDER=REPLACE_ME
|
||||
HOSTING_PROVIDER=
|
||||
HOSTING_ADDRESS=
|
||||
LOG_RETENTION=REPLACE_ME
|
||||
LOG_RETENTION=7 days
|
||||
DATA_RECIPIENTS=
|
||||
THIRD_COUNTRY_TRANSFERS=
|
||||
SUPERVISORY_AUTHORITY_NAME=
|
||||
SUPERVISORY_AUTHORITY_URL=
|
||||
|
||||
# One of: not_applicable, not_participating, participating.
|
||||
# Assess applicability under the VSBG; do not copy a status blindly.
|
||||
CONSUMER_DISPUTE_STATUS=REPLACE_ME
|
||||
# If "participating" is selected and LEGAL_STRICT=true, body and URL are required.
|
||||
CONSUMER_DISPUTE_STATUS=not_applicable
|
||||
CONSUMER_DISPUTE_BODY=
|
||||
CONSUMER_DISPUTE_URL=
|
||||
|
||||
ACCESSIBILITY_CONTACT=
|
||||
ACCESSIBILITY_STATUS=
|
||||
|
||||
# Runtime license issued by the standalone Universal License Platform.
|
||||
# No private keys or configurable public keys belong in this application.
|
||||
# -----------------------------------------------------------------------------
|
||||
# Runtime licence - issued by the standalone Universal License Platform
|
||||
# -----------------------------------------------------------------------------
|
||||
# No private signing key and no configurable issuer public key belong here.
|
||||
# Prefer LICENSE_TOKEN_FILE for Docker/Kubernetes secrets.
|
||||
LICENSE_TOKEN=
|
||||
LICENSE_TOKEN_FILE=
|
||||
LICENSE_INSTANCE_ID=
|
||||
LICENSE_MODE=offline
|
||||
LICENSE_SERVER_URL=https://license.trustednet.eu
|
||||
LICENSE_SERVER_URL=
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
LICENSE_REFRESH_INTERVAL=15m
|
||||
LICENSE_REQUEST_TIMEOUT=5s
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Bulk workspace / API
|
||||
# -----------------------------------------------------------------------------
|
||||
# The dedicated bulk image forces the intended bulk service mode and can require
|
||||
# the licensed "bulk_api" capability. BULK_MAX_ITEMS may be further reduced by
|
||||
# the licence limit "bulk_items".
|
||||
BULK_MAX_ITEMS=500
|
||||
BULK_MAX_BODY_BYTES=4194304
|
||||
BULK_REQUIRE_API_KEY=false
|
||||
BULK_API_KEY=
|
||||
BULK_API_KEY_FILE=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Removed / intentionally unsupported legacy marketing variables
|
||||
# -----------------------------------------------------------------------------
|
||||
# SALES_URL and PRICE_* are intentionally not runtime settings anymore. Pricing
|
||||
# and Pro advertising were removed from the public application UI.
|
||||
|
||||
51
.gitea/workflows/registry-bulk.yml
Normal file
51
.gitea/workflows/registry-bulk.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: release-tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'dev'
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_ORG: sendnrw
|
||||
DOCKER_LATEST: dev
|
||||
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.bulk
|
||||
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 }}:bulk-${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:bulk-${{ env.DOCKER_LATEST }}
|
||||
@@ -2,13 +2,13 @@ name: release-tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'dev'
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_ORG: sendnrw
|
||||
DOCKER_LATEST: latest
|
||||
DOCKER_LATEST: dev
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,3 +6,6 @@
|
||||
*.key
|
||||
*.private
|
||||
.DS_Store
|
||||
|
||||
secrets/*.txt
|
||||
!secrets/README.md
|
||||
|
||||
195
CHANGELOG.md
195
CHANGELOG.md
@@ -1,135 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.3 - 2026-07-21
|
||||
## 2.0.2
|
||||
|
||||
- Hardened EU AI Act guidance against the Commission Article 50 guidelines published on 20 July 2026.
|
||||
- Declaration schema 1.2 adds optional Article-50 `legalContext` categories.
|
||||
- Generator distinguishes voluntary transparency from potentially legally relevant disclosures.
|
||||
- Deepfake, public-interest text and artistic/creative/satirical/fictional context can be recorded explicitly.
|
||||
- Article badges use clearer first-exposure wording when the supplied context indicates a likely disclosure case.
|
||||
- Emoji embeds add adjacent plain text when a likely legal disclosure is indicated.
|
||||
- Public-interest assessment considers substantive review together with expressly named editorial responsibility.
|
||||
- Editorial responsibility can be classified as publisher responsibility or another responsible party and is stored explicitly.
|
||||
- Declaration pages show a cautious, non-binding Article-50 assessment and no longer imply editorial responsibility unless it is expressly stated.
|
||||
- Presets no longer infer human/editorial review.
|
||||
- Added first-exposure, machine-readable provider-marking and emoji-only disclosure guardrails.
|
||||
- Added configurable Impressum, privacy and accessibility pages plus security hardening for logs, proxy trust, CSP, HSTS and metrics.
|
||||
- `.env.example` ist nun die vollständige Runtime-Konfigurationsreferenz statt nur eines Minimalbeispiels.
|
||||
- Vertrauenswürdige Reverse-Proxy-Netze über `TRUSTED_PROXY_CIDRS`; weitergereichte Client-IP-Header werden nur von explizit vertrauenswürdigen Peers akzeptiert.
|
||||
- Datenschutzfreundliches Request-Logging mit `LOG_CLIENT_IP=false` als Standard und optionales `ENABLE_HSTS`.
|
||||
- Prometheus ist standardmäßig deaktiviert und kann nur mit Bearer-Token (`METRICS_TOKEN` / `METRICS_TOKEN_FILE`) aktiviert werden.
|
||||
- Mehrsprachige Betreiberseiten `/legal`, `/imprint`, `/privacy` und `/accessibility` mit konfigurierbaren Betreiber-, Datenschutz- und Barrierefreiheitsangaben.
|
||||
- `LEGAL_STRICT` als Fail-Fast-Prüfung für zentrale Betreiber-/Datenschutzfelder und die Konfiguration der Verbraucherstreitbeilegung.
|
||||
- Betreiberseiten und ihre statischen Assets bleiben auch bei einer Runtime-Lizenzstörung erreichbar.
|
||||
- Konfigurations-Regressionstests sowie neue Dokumentation in `docs/CONFIGURATION.md` und `docs/OPERATOR-PAGES.md`.
|
||||
- `SALES_URL` und `PRICE_*` bleiben bewusst entfernt, da Preisübersicht und Pro-Werbung nicht mehr Teil der öffentlichen Anwendung sind.
|
||||
|
||||
## Unreleased
|
||||
## 2.0.1
|
||||
|
||||
- No unreleased changes.
|
||||
- **Bulk Workspace wiederhergestellt:** Der dedizierte Bulk-Container liefert wieder eine vollständige Weboberfläche unter `/` und `/bulk`.
|
||||
- Visueller Workflow zum Anwenden eines gemeinsamen Artikelprofils auf viele URLs/IDs.
|
||||
- Erweiterter JSON-Modus für heterogene Bulk-Requests bleibt direkt mit `POST /v1/bulk/declarations` kompatibel.
|
||||
- Ergebnisansicht mit Erfolgs-/Fehlerstatus, Artikel-50-Einordnung sowie direkten Links zu Erklärung, Manifest und Badge.
|
||||
- JSON- und CSV-Export der Bulk-Ergebnisse.
|
||||
- API-Key-Eingabe wird ausschließlich im `sessionStorage` des aktuellen Browser-Tabs gehalten; der Schlüssel wird nicht in Ergebnisse geschrieben.
|
||||
- Bulk-Workspace respektiert `bulk_api`, `bulk_items`, `BULK_MAX_ITEMS` und `BULK_REQUIRE_API_KEY`.
|
||||
- Dedizierter Bulk-Container behält Fail-Closed-Lizenzierung, API-Key-Schutz, Read-only-Filesystem und alle 2.0-Härtungen bei.
|
||||
- `LICENSE_TOKEN_FILE` und `BULK_API_KEY_FILE` haben nun Vorrang vor gleichnamigen Environment-Werten; veraltete `.env`-Secrets können Docker-/Kubernetes-Secrets nicht mehr überschreiben.
|
||||
- Bulk-Oberfläche für alle acht unterstützten Sprachen vorbereitet; Deutsch und Englisch vollständig redaktionell ausgearbeitet, weitere Sprachen mit lokalisierten Kerntexten und englischem Fallback für seltene Verwaltungsbegriffe.
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Article 50 decision support
|
||||
|
||||
- regulatorischer Kontext als strukturierter Bestandteil des Schemas 1.2;
|
||||
- Deepfake-, Public-Interest-Text-, künstlerischer Kontext-, Human-Review-, Editorial-Responsibility-, First-Exposure- und Accessibility-Felder;
|
||||
- nicht bindender `/v1/article50-assessment.json`-Endpunkt;
|
||||
- explizitere Badge-Darstellung für markierte Artikel-50-Szenarien;
|
||||
- zusätzliche Warnung zur angemessenen Offenlegungsform bei künstlerischen/kreativen/satirischen/fiktionalen Deepfake-Kontexten;
|
||||
- `no-ai`-Preset behauptet keine menschliche KI-Prüfung mehr.
|
||||
|
||||
### Professional runtime
|
||||
|
||||
- `SERVICE_MODE=full|api|bulk`;
|
||||
- `REQUIRE_LICENSE` kann offizielle lizenzierte Deployments fail-closed betreiben;
|
||||
- konfigurierbares API-CORS über `API_ALLOWED_ORIGIN`;
|
||||
- White-Label-Capability;
|
||||
- neutralisierte UI-Begriffe für lizenzierte Anpassungen statt Pro-Werbung;
|
||||
- einheitlicher API-Fehler `licensed_feature_required`.
|
||||
|
||||
### Bulk & export
|
||||
|
||||
- lizenzierte Capability `bulk_api`;
|
||||
- lizenzierte Capability `export_bundle`;
|
||||
- dedizierter `Dockerfile.bulk`;
|
||||
- API-Key-Schutz für Bulk über Bearer oder `X-API-Key`;
|
||||
- Secret-Dateien via `BULK_API_KEY_FILE` und `LICENSE_TOKEN_FILE`;
|
||||
- Item-/Body-Limits und optionales Lizenzlimit `bulk_items`;
|
||||
- Bulk-Metriken;
|
||||
- Export-Bundle mit SHA-256-Digest der Deklaration.
|
||||
|
||||
### Deployment & supply chain
|
||||
|
||||
- gehärtete Kubernetes-Vorlage für den internen Bulk-Service;
|
||||
- separates Swarm-Bulk-Stack-Beispiel;
|
||||
- kommerzielles Compose-Beispiel mit Datei-Secrets;
|
||||
- Kubernetes ohne automatisch gemountete ServiceAccount-Tokens;
|
||||
- CI baut Full- und Bulk-Images für amd64/arm64, erzeugt SBOM/Provenance und signiert veröffentlichte Digests keyless mit Sigstore/Cosign;
|
||||
- OCI-Metadaten in beiden Dockerfiles.
|
||||
|
||||
### Documentation
|
||||
|
||||
- neue Dokumentation für Article-50-Decision-Support, Bulk API, kommerzielles Deployment und Lizenzstrategie;
|
||||
- README vollständig auf die 2.0-Produktarchitektur aktualisiert;
|
||||
- OpenAPI auf Version 2.0.0 erweitert.
|
||||
|
||||
## 1.6.2
|
||||
|
||||
- Added a dedicated, long-form background page at `/background`.
|
||||
- Added localized legal and editorial explanations for German, English, French, Spanish, Italian, Dutch, Portuguese and Polish.
|
||||
- Explained the separate obligations in Article 50(1)–(5), with a specific section for articles, websites, images, audio and video.
|
||||
- Added a clear distinction between legal labelling duties and broader voluntary disclosure.
|
||||
- Added official links to the EU AI Act, the Commission guidelines of 20 July 2026, the Article 50 Q&A, the Code of Practice and the EU icons.
|
||||
- Changed all “Background” navigation links to the new internal page.
|
||||
- Added language switching, canonical URLs and `hreflang` metadata to the background page.
|
||||
- Added regression tests for all eight languages and navigation.
|
||||
- eigene mehrsprachige Hintergrundseite zu Artikel 50 des EU AI Act;
|
||||
- interne Navigation auf `/background` umgestellt.
|
||||
|
||||
## 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.
|
||||
- professionellere Erklärungstexte;
|
||||
- gerasterte Tabellenansicht;
|
||||
- auswählbare Nachweisgrundlage.
|
||||
|
||||
## 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.
|
||||
- Lizenzprüfung an die eigenständige Universal License Platform angepasst;
|
||||
- sämtliche Lizenz-Erstellungs- und Admin-Funktionen aus dem Projekt entfernt.
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,19 +1,33 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM golang:1.26-alpine AS build
|
||||
ARG GO_IMAGE=golang:1.26-alpine
|
||||
FROM ${GO_IMAGE} AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY go.mod ./
|
||||
COPY .env.example ./.env.example
|
||||
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
|
||||
RUN CGO_ENABLED=0 GOOS=linux go test ./... && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server
|
||||
|
||||
FROM scratch
|
||||
ARG VERSION=2.0.3
|
||||
ARG REVISION=unknown
|
||||
ARG CREATED=unknown
|
||||
LABEL org.opencontainers.image.title="AI Disclosure Standard" \
|
||||
org.opencontainers.image.description="Open, multilingual AI usage disclosure service with optional licensed operational capabilities" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.revision="${REVISION}" \
|
||||
org.opencontainers.image.created="${CREATED}" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
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
|
||||
ENV LISTEN_ADDRESS=:8080 \
|
||||
SERVICE_MODE=full \
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
ENTRYPOINT ["/ai-disclosure"]
|
||||
|
||||
36
Dockerfile.bulk
Normal file
36
Dockerfile.bulk
Normal file
@@ -0,0 +1,36 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG GO_IMAGE=golang:1.26-alpine
|
||||
FROM ${GO_IMAGE} AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY go.mod ./
|
||||
COPY .env.example ./.env.example
|
||||
COPY third_party ./third_party
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
COPY web ./web
|
||||
RUN CGO_ENABLED=0 GOOS=linux go test ./... && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server
|
||||
|
||||
FROM scratch
|
||||
ARG VERSION=2.0.3
|
||||
ARG REVISION=unknown
|
||||
ARG CREATED=unknown
|
||||
LABEL org.opencontainers.image.title="AI Disclosure Standard Bulk" \
|
||||
org.opencontainers.image.description="Licensed bulk workspace and API for professional AI usage declaration processing" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.revision="${REVISION}" \
|
||||
org.opencontainers.image.created="${CREATED}" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
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 \
|
||||
SERVICE_MODE=bulk \
|
||||
REQUIRE_LICENSE=true \
|
||||
BULK_REQUIRE_API_KEY=true \
|
||||
API_ALLOWED_ORIGIN= \
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
ENTRYPOINT ["/ai-disclosure"]
|
||||
11
Makefile
11
Makefile
@@ -1,4 +1,6 @@
|
||||
.PHONY: run test test-license-client check build docker-build
|
||||
.PHONY: run test test-license-client check build docker-build docker-build-bulk docker-build-all
|
||||
|
||||
VERSION ?= 2.0.2
|
||||
|
||||
run:
|
||||
go run ./cmd/server
|
||||
@@ -21,4 +23,9 @@ build:
|
||||
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.3-local .
|
||||
docker build --build-arg VERSION=$(VERSION) -t ai-disclosure-standard:$(VERSION)-local .
|
||||
|
||||
docker-build-bulk:
|
||||
docker build -f Dockerfile.bulk --build-arg VERSION=$(VERSION) -t ai-disclosure-standard-bulk:$(VERSION)-local .
|
||||
|
||||
docker-build-all: docker-build docker-build-bulk
|
||||
|
||||
473
README.md
473
README.md
@@ -1,25 +1,73 @@
|
||||
# AI Disclosure Standard 1.6.3
|
||||
# AI Disclosure Standard 2.0.2
|
||||
|
||||
Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen.
|
||||
Ein mehrsprachiger, selbst hostbarer Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen.
|
||||
|
||||
## Funktionen
|
||||
Die Community-Basis bleibt offen und ohne Plattformzwang nutzbar. Für professionelle Betreiber stehen zusätzlich lizenzierbare Laufzeitfunktionen wie eigene Darstellung, Bulk-Verarbeitung, Export-Bundles und White-Label-Betrieb zur Verfügung. Lizenzen werden ausschließlich durch die separat betriebene **Universal License Platform** ausgestellt.
|
||||
|
||||
- einzelne Deklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
|
||||
- SVG-Badges, HTML-Erklärung und JSON-LD unter einem gemeinsamen Link;
|
||||
## Funktionsumfang
|
||||
|
||||
### Offener Kern
|
||||
|
||||
- Einzeldeklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
|
||||
- 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;
|
||||
- SVG-Badges, HTML-Erklärung und JSON-LD aus derselben Deklaration;
|
||||
- automatisch erzeugte Fließtext-Zusammenfassung und sauber gerasterte Tabellenansicht;
|
||||
- Nachweisgrundlagen `selfDeclared`, `technicallyRecorded`, `signed` und `verified`;
|
||||
- Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Portugiesisch und Polnisch;
|
||||
- Sprachumschalter direkt auf der Ergebnisseite;
|
||||
- mehrsprachige Hintergrundseite zu Artikel 50 des EU AI Act, Kennzeichnungspflichten und freiwilliger Transparenz;
|
||||
- optionale lizenzierte Funktionen für eigene Texte und Badge-Darstellung;
|
||||
- Offline-, Hybrid- und Online-Prüfung über die eigenständige Universal License Platform;
|
||||
- konfigurierbare Seiten für Impressum, Datenschutz und Barrierefreiheit;
|
||||
- CSP mit Request-Nonce, minimierte Logs, vertrauensgebundene Proxy-Header und gehärtete Container-Defaults;
|
||||
- Health-, Readiness- und optional geschützter Prometheus-Endpunkt;
|
||||
- Docker-, Kubernetes- und Docker-Swarm-Deployment.
|
||||
- Sprachumschalter direkt auf der Ergebnis-Seite;
|
||||
- mehrsprachige Hintergrundseite zu Artikel 50 des EU AI Act;
|
||||
- technisches, **nicht bindendes** Article-50-Decision-Support-Modell;
|
||||
- strukturierte Erfassung von Deepfake-Kontext, Texten zu Angelegenheiten von öffentlichem Interesse, substanzieller menschlicher Prüfung, redaktioneller Verantwortung, Erstexposition und Barrierefreiheit;
|
||||
- Validator, JSON-Schema und OpenAPI 3.1;
|
||||
- Health-, Readiness- und Prometheus-Endpunkte;
|
||||
- Docker, Docker Compose, Docker Swarm und Kubernetes;
|
||||
- SBOM, Build-Provenance und keyless signierte offizielle Container-Images in der mitgelieferten CI-Pipeline.
|
||||
|
||||
## Start unter Windows
|
||||
### Lizenzierbare Betriebsfunktionen
|
||||
|
||||
Die offizielle Distribution kennt folgende Capability-IDs:
|
||||
|
||||
```text
|
||||
custom_text
|
||||
custom_badge
|
||||
white_label
|
||||
bulk_api
|
||||
export_bundle
|
||||
```
|
||||
|
||||
Zusätzlich kann die Lizenzplattform numerische Limits liefern, beispielsweise:
|
||||
|
||||
```text
|
||||
bulk_items=500
|
||||
```
|
||||
|
||||
Die offene Standardfunktionalität hängt nicht von einer Lizenz ab. Ein Betreiber kann für offizielle kommerzielle Deployments mit `REQUIRE_LICENSE=true` festlegen, dass die Instanz ohne gültige Runtime-Lizenz nicht betriebsbereit ist und keine fachlichen Endpunkte bedient.
|
||||
|
||||
## Artikel-50-Unterstützung
|
||||
|
||||
Der Generator kann ergänzend zur eigentlichen KI-Nutzung Tatsachen erfassen, die für Artikel 50 relevant sein können:
|
||||
|
||||
- Deepfake bzw. realitätsähnlich erzeugter/manipulierter Inhalt;
|
||||
- Text zur Information der Öffentlichkeit über eine Angelegenheit von öffentlichem Interesse;
|
||||
- offensichtlich künstlerischer, kreativer, satirischer oder fiktionaler Kontext;
|
||||
- substanzielle menschliche Prüfung bzw. redaktionelle Kontrolle;
|
||||
- bestätigte redaktionelle Verantwortung;
|
||||
- Offenlegung spätestens bei der ersten Exposition;
|
||||
- Berücksichtigung anwendbarer Barrierefreiheitsanforderungen.
|
||||
|
||||
Daraus erzeugt die Anwendung eine konservative technische Einordnung unter:
|
||||
|
||||
```text
|
||||
GET /v1/article50-assessment.json
|
||||
```
|
||||
|
||||
Diese Einordnung ist ausdrücklich **keine Rechtsberatung und keine automatische Konformitätsentscheidung**. Die Rohdaten bleiben Teil der Deklaration und können unabhängig von der Bewertung weiterverarbeitet werden.
|
||||
|
||||
Weitere Details: [`docs/ARTICLE-50-DECISION-SUPPORT.md`](docs/ARTICLE-50-DECISION-SUPPORT.md)
|
||||
|
||||
## Schnellstart
|
||||
|
||||
Unter Windows:
|
||||
|
||||
```powershell
|
||||
go run .\cmd\server
|
||||
@@ -28,61 +76,174 @@ go run .\cmd\server
|
||||
Danach:
|
||||
|
||||
```text
|
||||
Generator: http://localhost:8080/
|
||||
Produktseite: http://localhost:8080/product
|
||||
Hintergrund: http://localhost:8080/background
|
||||
Impressum: http://localhost:8080/impressum
|
||||
Datenschutz: http://localhost:8080/datenschutz
|
||||
Barrierefrei: http://localhost:8080/barrierefreiheit
|
||||
Healthcheck: http://localhost:8080/healthz
|
||||
Funktionen: http://localhost:8080/v1/capabilities
|
||||
Generator: http://localhost:8080/
|
||||
Produktseite: http://localhost:8080/product
|
||||
Hintergrund: http://localhost:8080/background
|
||||
Capabilities: http://localhost:8080/v1/capabilities
|
||||
Health: http://localhost:8080/healthz
|
||||
Readiness: http://localhost:8080/readyz
|
||||
```
|
||||
|
||||
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
|
||||
Go lädt `.env` beim direkten Start nicht automatisch. Docker Compose kann sie explizit verwenden:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
docker compose --env-file .env up -d --build
|
||||
```
|
||||
|
||||
## Lizenzprüfung
|
||||
## Betriebsmodi
|
||||
|
||||
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**.
|
||||
`SERVICE_MODE` trennt öffentliche Oberfläche und Server-to-Server-Betrieb:
|
||||
|
||||
Der Produktserver enthält nur den Laufzeit-Client und akzeptiert:
|
||||
| Modus | Zweck |
|
||||
|---|---|
|
||||
| `full` | Generator, Produkt-/Hintergrundseiten, Deklarationen und APIs |
|
||||
| `api` | Deklarations-, Badge- und Maschinen-APIs ohne öffentliche Generator-/Produktseite |
|
||||
| `bulk` | eigenständiger Bulk Workspace plus Bulk-/Validierungs-/Betriebs-APIs |
|
||||
|
||||
```env
|
||||
LICENSE_TOKEN=...
|
||||
LICENSE_MODE=offline
|
||||
LICENSE_SERVER_URL=
|
||||
LICENSE_INSTANCE_ID=
|
||||
Der dedizierte Bulk-Container setzt bereits:
|
||||
|
||||
```text
|
||||
SERVICE_MODE=bulk
|
||||
REQUIRE_LICENSE=true
|
||||
BULK_REQUIRE_API_KEY=true
|
||||
API_ALLOWED_ORIGIN=
|
||||
```
|
||||
|
||||
Der Produktname ist fest verdrahtet:
|
||||
## Professioneller Bulk-Betrieb
|
||||
|
||||
Der Bulk-Container enthält wieder die vollständige browserbasierte **Bulk Workspace**-Oberfläche. Sie ist unter `/` und `/bulk` erreichbar und ergänzt die API, statt sie zu ersetzen. API-Keys werden im Browser nur im `sessionStorage` des aktuellen Tabs gehalten.
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.bulk -t ai-disclosure-standard-bulk:2.0.2 .
|
||||
```
|
||||
|
||||
Die Oberfläche ist nach dem Start erreichbar unter:
|
||||
|
||||
```text
|
||||
http://localhost:8080/
|
||||
http://localhost:8080/bulk
|
||||
```
|
||||
|
||||
Der programmatische Bulk-Endpunkt lautet:
|
||||
|
||||
```text
|
||||
POST /v1/bulk/declarations
|
||||
```
|
||||
|
||||
Er benötigt in der offiziellen Bulk-Distribution:
|
||||
|
||||
1. eine gültige Lizenz mit Capability `bulk_api`;
|
||||
2. optional ein Lizenzlimit `bulk_items`;
|
||||
3. einen serverseitigen API-Schlüssel.
|
||||
|
||||
Authentifizierung:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <bulk-api-key>
|
||||
```
|
||||
|
||||
oder:
|
||||
|
||||
```http
|
||||
X-API-Key: <bulk-api-key>
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "article-42",
|
||||
"parameters": {
|
||||
"mode": "article",
|
||||
"lang": "de",
|
||||
"textExtent": "partial",
|
||||
"textReview": "expert",
|
||||
"publicInterestText": "true",
|
||||
"substantialHumanReview": "true",
|
||||
"editorialResponsibilityConfirmed": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Grenzen:
|
||||
|
||||
```env
|
||||
BULK_MAX_ITEMS=500
|
||||
BULK_MAX_BODY_BYTES=4194304
|
||||
BULK_REQUIRE_API_KEY=true
|
||||
BULK_API_KEY_FILE=/run/secrets/bulk/key
|
||||
```
|
||||
|
||||
Ist `LICENSE_TOKEN_FILE` bzw. `BULK_API_KEY_FILE` gesetzt, ist die angegebene Datei **autoritativ** und hat Vorrang vor einem eventuell gleichzeitig vorhandenen `LICENSE_TOKEN` bzw. `BULK_API_KEY` aus `.env`. Dadurch können veraltete Environment-Werte Docker-/Kubernetes-Secrets nicht unbemerkt überschreiben.
|
||||
|
||||
Siehe [`docs/BULK-API.md`](docs/BULK-API.md) und [`docs/BULK-WORKSPACE.md`](docs/BULK-WORKSPACE.md).
|
||||
|
||||
## Supply-Chain-Sicherheit
|
||||
|
||||
Die CI-Pipeline erzeugt SBOM/Provenance und signiert veröffentlichte Full- und Bulk-Container keyless mit Sigstore/Cosign über GitHub OIDC. Für produktive Deployments sollte nach Digest ausgerollt und die Signatur verifiziert werden. Details: [`docs/SUPPLY-CHAIN.md`](docs/SUPPLY-CHAIN.md).
|
||||
|
||||
## Export-Bundle
|
||||
|
||||
Mit der Capability `export_bundle` steht zur Verfügung:
|
||||
|
||||
```text
|
||||
GET /v1/declaration.bundle.json
|
||||
```
|
||||
|
||||
Das Bundle enthält:
|
||||
|
||||
- Deklaration;
|
||||
- Article-50-Assessment;
|
||||
- kanonische URLs;
|
||||
- Erzeugungszeitpunkt des Bundles;
|
||||
- einen SHA-256-Digest der serialisierten Deklaration.
|
||||
|
||||
Der Digest ist ein Integritätsmerkmal, **keine digitale Signatur**.
|
||||
|
||||
## Lizenzprüfung
|
||||
|
||||
Dieses Repository stellt keine Lizenzen aus und enthält keine privaten Lizenzschlüssel, Keygen-Funktionen, Lizenzverwaltung oder Admin-Oberfläche. Diese Aufgaben liegen ausschließlich in der eigenständigen Universal License Platform.
|
||||
|
||||
Produkt-ID:
|
||||
|
||||
```text
|
||||
ai-disclosure-standard
|
||||
```
|
||||
|
||||
Für die vorhandenen Funktionen verwendet die Plattform diese Feature-IDs:
|
||||
Der Produktserver enthält nur den Runtime-Client. Typische Konfiguration:
|
||||
|
||||
```text
|
||||
custom_text
|
||||
custom_badge
|
||||
white_label
|
||||
```env
|
||||
LICENSE_TOKEN=...
|
||||
LICENSE_MODE=hybrid
|
||||
LICENSE_SERVER_URL=https://licenses.example.org
|
||||
LICENSE_INSTANCE_ID=publisher-01
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
```
|
||||
|
||||
### Vertrauensschlüssel einbetten
|
||||
Alternativ können Secrets über Dateien eingebunden werden:
|
||||
|
||||
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:
|
||||
```env
|
||||
LICENSE_TOKEN_FILE=/run/secrets/license/token
|
||||
```
|
||||
|
||||
### Vertrauensschlüssel
|
||||
|
||||
Die öffentlichen Issuer- und Lease-Schlüssel werden vor dem Build in
|
||||
|
||||
```text
|
||||
internal/app/trusted_keys.json
|
||||
```
|
||||
|
||||
PowerShell-Beispiel:
|
||||
eingebettet. Es gibt absichtlich keinen zur Laufzeit austauschbaren `LICENSE_PUBLIC_KEY`.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest `
|
||||
@@ -92,185 +253,147 @@ Invoke-WebRequest `
|
||||
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.
|
||||
Die Laufzeitprüfung unterstützt Offline-, Hybrid- und Online-Modus. Hybrid nutzt einen signierten Lease-Cache und eignet sich für hochverfügbare Installationen, die kurze Ausfälle des Lizenzdienstes tolerieren sollen.
|
||||
|
||||
### Prüfmodi
|
||||
Siehe [`docs/LICENSE-INTEGRATION.md`](docs/LICENSE-INTEGRATION.md).
|
||||
|
||||
**Offline** prüft den signierten Lizenz-Token ausschließlich lokal.
|
||||
## Produktionsdeployment mit Docker Compose
|
||||
|
||||
```env
|
||||
LICENSE_MODE=offline
|
||||
LICENSE_TOKEN=...
|
||||
Für den offenen Einzelserver:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
**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.
|
||||
Für ein Beispiel mit Runtime-Secrets und separatem Bulk-Dienst:
|
||||
|
||||
```env
|
||||
LICENSE_MODE=hybrid
|
||||
LICENSE_TOKEN=...
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
```bash
|
||||
mkdir -p secrets
|
||||
printf '%s' '<LICENSE_TOKEN>' > secrets/license_token.txt
|
||||
printf '%s' '<LONG_RANDOM_API_KEY>' > secrets/bulk_api_key.txt
|
||||
|
||||
BASE_URL=https://ai.example.org \
|
||||
LICENSE_SERVER_URL=https://licenses.example.org \
|
||||
docker compose -f compose.commercial.yaml up -d --build
|
||||
```
|
||||
|
||||
**Online** benötigt eine erfolgreiche aktuelle Prüfung durch die Plattform.
|
||||
Der Bulk-Dienst nutzt `OUTPUT_BASE_URL`, damit seine Resultate auf die öffentliche Full-/API-Instanz verweisen können. Die Bulk-Instanz liefert selbst den Bulk Workspace, aber bewusst keine öffentlichen Deklarationsseiten aus. Der Bulk-Port ist in diesem Beispiel nur an `127.0.0.1:8081` gebunden. In einer echten Produktionsumgebung sollte der Bulk-Service intern bleiben oder hinter einem authentifizierenden API-Gateway betrieben werden.
|
||||
|
||||
```env
|
||||
LICENSE_MODE=online
|
||||
LICENSE_TOKEN=...
|
||||
## Kubernetes
|
||||
|
||||
Öffentliche Anwendung:
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/kubernetes.yaml
|
||||
```
|
||||
|
||||
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.
|
||||
Optionales Lizenz-Secret:
|
||||
|
||||
Der Client verwendet die Plattform-API:
|
||||
|
||||
```text
|
||||
POST /api/v1/licenses/validate
|
||||
```bash
|
||||
kubectl create secret generic ai-disclosure-license \
|
||||
--from-literal=token='<LICENSE_TOKEN>'
|
||||
```
|
||||
|
||||
und unterstützt für bestehende Installationen weiterhin:
|
||||
Lizenzierter interner Bulk-Dienst:
|
||||
|
||||
```text
|
||||
POST /v1/introspect
|
||||
```bash
|
||||
kubectl create secret generic ai-disclosure-commercial \
|
||||
--from-literal=license-token='<LICENSE_TOKEN>' \
|
||||
--from-literal=bulk-api-key='<LONG_RANDOM_API_KEY>'
|
||||
|
||||
kubectl apply -f deploy/kubernetes-bulk.yaml
|
||||
```
|
||||
|
||||
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).
|
||||
Das Bulk-Manifest enthält absichtlich **keinen Ingress**.
|
||||
|
||||
Siehe [`docs/COMMERCIAL-DEPLOYMENT.md`](docs/COMMERCIAL-DEPLOYMENT.md).
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Betrieb und Sicherheit
|
||||
Die Datei [`.env.example`](.env.example) ist ab Version 2.0.2 die **vollständige Referenz** aller vom offiziellen Server unterstützten Runtime-Variablen. Ein Regressionstest stellt sicher, dass die dokumentierten Kernvariablen nicht versehentlich aus der Beispieldatei verschwinden.
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `LISTEN_ADDRESS` | `:8080` | HTTP-Adresse |
|
||||
| `BASE_URL` | `http://localhost:8080` | öffentliche Origin ohne Pfad, Query oder Fragment |
|
||||
| `PUBLIC_NAME` | `AI Usage Disclosure` | sichtbarer Produktname |
|
||||
| `CONTACT_URL` | Projektseite | Kontakt-/Informationsseite |
|
||||
| `DEFAULT_LANGUAGE` | `de` | Standardsprache |
|
||||
| `TRUST_PROXY` | `false` | Proxy-Header nur berücksichtigen, wenn zusätzlich vertrauenswürdige Netze gesetzt sind |
|
||||
| `TRUSTED_PROXY_CIDRS` | leer | kommagetrennte CIDRs der tatsächlich kontrollierten Reverse Proxies |
|
||||
| `LOG_CLIENT_IP` | `false` | Client-IP in Anwendungslogs aufnehmen; aus Datenschutzgründen standardmäßig deaktiviert |
|
||||
| `ENABLE_HSTS` | `true` | HSTS bei einer `https://`-Basis-URL senden |
|
||||
| `METRICS_ENABLED` | `false` | `/metrics` aktivieren |
|
||||
| `METRICS_TOKEN` | leer | bei aktiviertem `/metrics` verpflichtender Bearer-Token; auch als `METRICS_TOKEN_FILE` |
|
||||
Neu bzw. explizit dokumentiert sind insbesondere:
|
||||
|
||||
### Betreiber- und Datenschutzangaben
|
||||
- vertrauenswürdige Proxy-Netze über `TRUSTED_PROXY_CIDRS`;
|
||||
- datensparsame Request-Logs über `LOG_CLIENT_IP=false`;
|
||||
- optionales HSTS über `ENABLE_HSTS`;
|
||||
- standardmäßig deaktivierte und per Bearer-Token geschützte Prometheus-Metriken;
|
||||
- `_FILE`-Varianten für Lizenz-, Bulk- und Metrics-Secrets;
|
||||
- vollständige Betreiber-, Datenschutz- und Barrierefreiheitsdaten für `/legal`, `/privacy` und `/accessibility`;
|
||||
- `LEGAL_STRICT` als Fail-Fast-Prüfung gegen leere Produktions-Platzhalter.
|
||||
|
||||
Die Seiten `/impressum`, `/datenschutz` und `/barrierefreiheit` werden aus Umgebungsvariablen erzeugt. Mindestens `LEGAL_NAME`, `LEGAL_ADDRESS`, `LEGAL_EMAIL`, `HOSTING_PROVIDER`, `LOG_RETENTION` und `CONSUMER_DISPUTE_STATUS` müssen vor öffentlichem Betrieb geprüft werden. Mit `LEGAL_STRICT=true` startet der Server nicht, solange Pflichtwerte fehlen oder Platzhalter wie `REPLACE_ME` enthalten.
|
||||
`SALES_URL` und die früheren `PRICE_*`-Variablen sind bewusst **nicht** mehr unterstützt: Preisübersicht und Pro-Werbung wurden aus der öffentlichen Anwendung entfernt. Tote Konfigurationsparameter werden nicht weitergeführt.
|
||||
|
||||
Weitere Variablen stehen vollständig in [`.env.example`](.env.example). Dazu gehören Vertretungsberechtigte, Register- und Umsatzsteuerangaben, redaktionell Verantwortliche, Datenschutzkontakt, Empfänger, Drittlandübermittlungen, Aufsichtsbehörde, Verbraucherstreitbeilegung und Barrierefreiheitskontakt.
|
||||
Eine kommentierte Beschreibung aller Variablen steht zusätzlich in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md).
|
||||
|
||||
### Lizenzprüfung
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `LICENSE_TOKEN` | leer | von der Universal License Platform ausgestellter Token |
|
||||
| `LICENSE_MODE` | `offline` | Mindestmodus `offline`, `hybrid` oder `online` |
|
||||
| `LICENSE_SERVER_URL` | leer | optionaler Prüfserver-Override |
|
||||
| `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 |
|
||||
|
||||
Die mitgelieferten Rechtstexte sind eine technisch abgestimmte Vorlage, keine individuelle Rechtsberatung. Die konkrete Einordnung hängt unter anderem von Betreiber, Hosting, Vertragsmodell, Zielgruppe, Zusatzdiensten und redaktionellen Inhalten ab. Siehe [`docs/LEGAL-AND-SECURITY.md`](docs/LEGAL-AND-SECURITY.md) und den [`Reviewbericht vom 20. Juli 2026`](docs/REVIEW-2026-07-20.md).
|
||||
|
||||
Für die Abgrenzung zum EU AI Act und insbesondere zu Artikel 4 und Artikel 50 siehe außerdem [`docs/EU-AI-ACT-COMPLIANCE.md`](docs/EU-AI-ACT-COMPLIANCE.md). Das JSON-LD dieses Projekts ist ergänzende Dokumentation und kein automatischer Ersatz für eine Provider-Markierung nach Artikel 50 Absatz 2.
|
||||
|
||||
## API
|
||||
## Wichtige API-Endpunkte
|
||||
|
||||
```text
|
||||
GET /badge/{preset}.svg
|
||||
GET /v1/badge.svg
|
||||
GET /background
|
||||
GET /impressum
|
||||
GET /datenschutz
|
||||
GET /barrierefreiheit
|
||||
GET /declaration
|
||||
GET /v1/declaration.json
|
||||
GET /v1/article50-assessment.json
|
||||
GET /v1/declaration.bundle.json # licensed: export_bundle
|
||||
POST /v1/bulk/declarations # licensed: bulk_api
|
||||
POST /v1/validate
|
||||
GET /v1/capabilities
|
||||
GET /healthz
|
||||
GET /readyz
|
||||
GET /metrics optional, standardmäßig deaktiviert
|
||||
GET /metrics # optional, Bearer-geschützt
|
||||
GET /legal # UI modes
|
||||
GET /privacy # UI modes
|
||||
GET /accessibility # UI modes
|
||||
```
|
||||
|
||||
Beispiel für eine Artikelerklärung:
|
||||
## Sicherheit
|
||||
|
||||
```text
|
||||
/declaration?mode=article&textExtent=none&textReview=none&imageExtent=full&imageReview=editorial&researchExtent=assisted&researchReview=expert&assurance=technicallyRecorded&lang=de
|
||||
```
|
||||
- minimales Scratch-Runtime-Image;
|
||||
- non-root UID/GID `65532`;
|
||||
- read-only root filesystem in den mitgelieferten Deployments;
|
||||
- alle Linux-Capabilities entfernt;
|
||||
- `no-new-privileges`;
|
||||
- Kubernetes `RuntimeDefault` seccomp;
|
||||
- keine ServiceAccount-Tokens in Kubernetes-Pods;
|
||||
- Lizenz- und Bulk-Secrets können als Dateien gemountet werden;
|
||||
- CSP, `X-Content-Type-Options`, `X-Frame-Options`, Referrer- und Permissions-Policy;
|
||||
- konfigurierbares CORS;
|
||||
- Proxy-Header werden nur von explizit vertrauenswürdigen CIDR-Netzen akzeptiert;
|
||||
- Client-IP-Logging ist standardmäßig deaktiviert;
|
||||
- optionales HSTS für HTTPS-only Deployments;
|
||||
- Prometheus ist standardmäßig deaktiviert und bei Aktivierung Bearer-geschützt;
|
||||
- Request-IDs und strukturierte JSON-Logs;
|
||||
- Body- und Itemlimits für die Bulk-API;
|
||||
- optionale API-Key-Authentifizierung des Bulk-Endpunkts;
|
||||
- Lizenzstatus wird bei `REQUIRE_LICENSE=true` nicht nur in Readiness, sondern auch im Request-Pfad durchgesetzt.
|
||||
|
||||
### SVG-Darstellungen
|
||||
## Open Source und kommerzieller Betrieb
|
||||
|
||||
Der Query-Parameter `theme` unterstützt drei Darstellungen:
|
||||
Der aktuelle Quellstand trägt die mitgelieferte MIT-Lizenz. Damit eignet er sich als offene Referenzimplementierung und ist frei integrierbar. Die Runtime-Capabilities sind eine technische Produktgrenze der offiziellen Distribution und der Universal License Platform.
|
||||
|
||||
| Wert | Ausgabe |
|
||||
|---|---|
|
||||
| `color` | klassisches zweifarbiges Text-Badge |
|
||||
| `mono` | monochromes Text-Badge |
|
||||
| `emoji` | quadratisches, rein grafisches SVG-Symbol mit zugänglichem Titel |
|
||||
Für ein Geschäftsmodell, das **zusätzlich** urheberrechtlich zwischen Community- und kommerzieller Distribution unterscheiden soll, sollte vor Veröffentlichung bewusst eine Dual-Licensing- bzw. Open-Core-Strategie festgelegt werden. Die vorhandene MIT-Lizenz selbst räumt weitgehende Nutzungs-, Änderungs- und Weitergaberechte ein. Siehe [`docs/LICENSING-STRATEGY.md`](docs/LICENSING-STRATEGY.md).
|
||||
|
||||
Die Emoji-Variante verwendet je nach Preset ein Mensch-, Recherche-, Zusammenfassungs- oder Blitzsymbol. Beispiel:
|
||||
|
||||
```text
|
||||
/badge/research.svg?theme=emoji&lang=de&link=auto
|
||||
```
|
||||
|
||||
Bei `theme=emoji` werden `style=flat` und `style=flat-square` ignoriert, da die Ausgabe immer quadratisch ist. Mit der Pro-Funktion `custom_badge` steuert `leftColor` die Symbolfarbe und `rightColor` die Hintergrundfarbe.
|
||||
|
||||
|
||||
### 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:
|
||||
## Entwicklung und Qualitätssicherung
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
## Projektgrenze
|
||||
entspricht im Wesentlichen:
|
||||
|
||||
Im Hauptprojekt verbleiben ausschließlich:
|
||||
```bash
|
||||
gofmt
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
cd third_party/license-platform-client
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
- ein eingebetteter öffentlicher Trust Store;
|
||||
- ein verifikationsfähiger Client;
|
||||
- Feature- und Limit-Abfragen;
|
||||
- optionaler signierter Lease-Cache.
|
||||
Container:
|
||||
|
||||
Nicht enthalten sind:
|
||||
```bash
|
||||
make docker-build-all
|
||||
```
|
||||
|
||||
- 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.
|
||||
|
||||
### Art.-50-Kontext (Schema 1.2)
|
||||
|
||||
Optional kann `legalContext` den rechtlich relevanten Veröffentlichungskontext als Selbsteinordnung dokumentieren: `deepfake`, `publicInterestText`, `artisticCreativeSatiricalFictional` oder `otherVoluntary`. Der Generator nutzt diese Angaben für eine vorsichtige, nicht bindende Einschätzung und für eine eindeutigere Erstkennzeichnung. Ein Deepfake ist dabei nicht jedes KI-generierte Bild. Die Einordnung muss durch den Publisher anhand des konkreten Inhalts und Veröffentlichungskontexts erfolgen.
|
||||
|
||||
Bei `publicInterestText` berücksichtigt die Einschätzung, ob eine substanzielle inhaltliche Prüfung (`editorial` oder `expert`) und eine ausdrücklich benannte `editorialResponsibility` dokumentiert sind. Bei einer als wahrscheinlich kennzeichnungspflichtig eingeordneten Nutzung erzeugt der Badge eine unmittelbar verständliche Beschriftung statt nur „Artikeltransparenz“. Im `emoji`-Modus ergänzt der generierte Einbettungscode bei solchen Fällen zusätzlich sichtbaren Text.
|
||||
Die CI baut und veröffentlicht bei Pushes auf `main` sowohl das normale als auch das dedizierte Bulk-Image für `linux/amd64` und `linux/arm64` und fordert SBOM/Provenance-Ausgabe von BuildKit an.
|
||||
|
||||
43
SECURITY.md
43
SECURITY.md
@@ -1,30 +1,33 @@
|
||||
# Security policy
|
||||
# Security
|
||||
|
||||
Version 1.6.x is the supported line in this project archive.
|
||||
## Supported version
|
||||
|
||||
Security fixes are intended for the current 2.x line.
|
||||
|
||||
## Runtime hardening
|
||||
|
||||
The supplied container/deployment examples run non-root with a read-only root filesystem, dropped Linux capabilities and no privilege escalation. Kubernetes examples use `RuntimeDefault` seccomp and disable automatic service-account token mounting.
|
||||
|
||||
Runtime secrets can be read from files through `LICENSE_TOKEN_FILE` and `BULK_API_KEY_FILE`. Do not commit real licence tokens or API keys.
|
||||
|
||||
The dedicated bulk image is fail-closed by default: it requires a valid runtime licence, the `bulk_api` capability and an API key configuration before becoming ready.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report suspected vulnerabilities privately to the project operator before public disclosure. Configure the operator's security contact outside this source archive and publish it through the project website or a `security.txt` file at the deployment edge.
|
||||
Do not open public issues containing active licence tokens, API keys, private infrastructure details or exploitable security findings. Use the project contact channel configured by the maintainer.
|
||||
|
||||
## Secure defaults
|
||||
## Trust store
|
||||
|
||||
- HTML responses use a restrictive Content Security Policy with a fresh nonce.
|
||||
- Request IDs are length- and character-validated before they enter logs.
|
||||
- Query strings and client IP addresses are not written to application logs by default.
|
||||
- Forwarded client addresses are trusted only from explicitly configured proxy CIDRs.
|
||||
- `/metrics` is disabled by default and requires a Bearer token when enabled.
|
||||
- JSON validation requests are size-limited, strictly decoded and concurrency-limited.
|
||||
- The reference container runs without root, Linux capabilities or a writable root filesystem.
|
||||
Only public issuer/lease keys from the operator's Universal License Platform belong in `internal/app/trusted_keys.json`. Never place private signing keys in this repository or image.
|
||||
|
||||
See [`docs/LEGAL-AND-SECURITY.md`](docs/LEGAL-AND-SECURITY.md) for deployment controls that remain the operator's responsibility, including TLS, edge rate limits, patching, secret management, network segmentation and log deletion.
|
||||
## Proxy trust and client addresses
|
||||
|
||||
## License integration
|
||||
`TRUST_PROXY=true` requires `TRUSTED_PROXY_CIDRS`. Forwarded address headers are used only when the directly connected peer belongs to one of those networks. Client IPs are omitted from request logs unless `LOG_CLIENT_IP=true` is explicitly enabled.
|
||||
|
||||
- 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 signed lease tokens, not private keys.
|
||||
## Prometheus
|
||||
|
||||
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.
|
||||
`/metrics` is disabled by default. `METRICS_ENABLED=true` requires a bearer secret of at least 32 characters via `METRICS_TOKEN` or preferably `METRICS_TOKEN_FILE`. Do not expose the endpoint publicly even when authentication is enabled.
|
||||
|
||||
## Operator information
|
||||
|
||||
`LEGAL_STRICT=true` is a deployment guard against empty or placeholder operator/privacy configuration. It does not certify legal compliance and should be combined with a review of the actual deployment and applicable jurisdiction.
|
||||
|
||||
53
UCNG-CONTENT-TEST.md
Normal file
53
UCNG-CONTENT-TEST.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# UCNG Content Test Version
|
||||
|
||||
Diese Fassung ist für einen separaten Test-Branch gedacht und setzt die in den **UCNG Brand & Identity Guidelines, Version 0.1** definierte Content-Positionierung auf die bestehende Anwendung um.
|
||||
|
||||
## Leitlinie
|
||||
|
||||
- Kernsprache der Anwendung: Deutsch
|
||||
- Primärer Claim: **Declare how content was made.**
|
||||
- UCNG wird als offenes, neutrales Notationssystem für Content-Declarations positioniert.
|
||||
- Die Declaration beschreibt den deklarierten Entstehungs- und Transformationskontext; sie bewertet weder Wahrheit noch Qualität.
|
||||
- Human-readable Declaration und Machine Layer werden als zwei Darstellungen derselben Angaben kommuniziert.
|
||||
- Regulatorischer Kontext, insbesondere EU AI Act / Artikel 50, bleibt als optionale Entscheidungshilfe von der UCNG-Kernaussage getrennt.
|
||||
- `Declared`, `Attested` und `Verified` werden begrifflich nicht gleichgesetzt.
|
||||
|
||||
## Bewusst unverändert
|
||||
|
||||
Diese Content-Testfassung verändert **nicht**:
|
||||
|
||||
- API-Endpunkte oder Query-Parameter
|
||||
- das JSON-LD-Datenmodell
|
||||
- `AIUsageDeclaration` als bestehenden technischen `@type`
|
||||
- Schema-Version 1.2
|
||||
- die bestehenden Enum-Werte für `aiExtent`
|
||||
- Lizenz-, Deployment- oder Validierungslogik
|
||||
|
||||
## Anzeige-Mapping der bestehenden aiExtent-Werte
|
||||
|
||||
Da das bestehende technische Schema noch nicht die vollständigen UCNG-Klassen als eigene Werte modelliert, wird nur die sichtbare Bezeichnung angepasst:
|
||||
|
||||
| Bestehender Wert | Sichtbare UCNG-Einordnung |
|
||||
|---|---|
|
||||
| `none` | `HUMAN` |
|
||||
| `assisted` | `AI ASSISTED` |
|
||||
| `partial` | `MIXED · teilweise generiert` |
|
||||
| `mostly` | `AI GENERATED · überwiegend` |
|
||||
| `full` | `AI GENERATED` |
|
||||
|
||||
**AI TRANSFORMED** wurde absichtlich nicht als neue allgemeine Datenklasse eingeführt. Das wäre eine Änderung des technischen Modells und gehört in eine spätere UCNG-Spezifikations-/Schema-Iteration, nicht in einen reinen Content-Test.
|
||||
|
||||
## Sprachen
|
||||
|
||||
Die vorhandenen Locales bleiben erhalten:
|
||||
|
||||
- Deutsch
|
||||
- Englisch
|
||||
- Französisch
|
||||
- Spanisch
|
||||
- Italienisch
|
||||
- Niederländisch
|
||||
- Portugiesisch
|
||||
- Polnisch
|
||||
|
||||
Deutsch ist die Leitfassung. Der offizielle englische Claim und die standardnahen Klassennamen (`HUMAN`, `AI ASSISTED`, `AI GENERATED`, `MIXED`, `Declared`, `Verified`) bleiben sprachübergreifend konsistent, während erklärende UI-Texte lokalisiert sind.
|
||||
97
compose.commercial.yaml
Normal file
97
compose.commercial.yaml
Normal file
@@ -0,0 +1,97 @@
|
||||
# Production-oriented example. Create the files described in secrets/README.md first.
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: "2.0.2"
|
||||
image: ai-disclosure-standard:2.0.2
|
||||
environment:
|
||||
BASE_URL: "${BASE_URL:?set BASE_URL}"
|
||||
OUTPUT_BASE_URL: "${BASE_URL:?set BASE_URL}"
|
||||
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}"
|
||||
SERVICE_MODE: full
|
||||
API_ALLOWED_ORIGIN: "${API_ALLOWED_ORIGIN:-*}"
|
||||
LICENSE_TOKEN_FILE: /run/secrets/license_token
|
||||
LICENSE_MODE: "${LICENSE_MODE:-hybrid}"
|
||||
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
|
||||
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-commercial-app}"
|
||||
LICENSE_CACHE_FILE: /data/license-lease.json
|
||||
REQUIRE_LICENSE: "${REQUIRE_LICENSE:-false}"
|
||||
WHITE_LABEL: "${WHITE_LABEL:-false}"
|
||||
secrets:
|
||||
- license_token
|
||||
volumes:
|
||||
- app-license-cache:/data
|
||||
ports:
|
||||
- "8080:8080"
|
||||
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
|
||||
|
||||
bulk:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.bulk
|
||||
args:
|
||||
VERSION: "2.0.2"
|
||||
image: ai-disclosure-standard-bulk:2.0.2
|
||||
environment:
|
||||
BASE_URL: "${BULK_BASE_URL:-http://bulk:8080}"
|
||||
OUTPUT_BASE_URL: "${BASE_URL:?set BASE_URL}"
|
||||
DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}"
|
||||
SERVICE_MODE: bulk
|
||||
API_ALLOWED_ORIGIN: "${BULK_ALLOWED_ORIGIN:-}"
|
||||
LICENSE_TOKEN_FILE: /run/secrets/license_token
|
||||
LICENSE_MODE: "${LICENSE_MODE:-hybrid}"
|
||||
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
|
||||
LICENSE_INSTANCE_ID: "${BULK_INSTANCE_ID:-commercial-bulk}"
|
||||
LICENSE_CACHE_FILE: /data/license-lease.json
|
||||
REQUIRE_LICENSE: "true"
|
||||
BULK_REQUIRE_API_KEY: "true"
|
||||
BULK_API_KEY_FILE: /run/secrets/bulk_api_key
|
||||
BULK_MAX_ITEMS: "${BULK_MAX_ITEMS:-500}"
|
||||
BULK_MAX_BODY_BYTES: "${BULK_MAX_BODY_BYTES:-4194304}"
|
||||
secrets:
|
||||
- license_token
|
||||
- bulk_api_key
|
||||
volumes:
|
||||
- bulk-license-cache:/data
|
||||
# Keep this private in production; the loopback mapping is for local operators.
|
||||
ports:
|
||||
- "127.0.0.1:8081:8080"
|
||||
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
|
||||
|
||||
secrets:
|
||||
license_token:
|
||||
file: ./secrets/license_token.txt
|
||||
bulk_api_key:
|
||||
file: ./secrets/bulk_api_key.txt
|
||||
|
||||
volumes:
|
||||
app-license-cache:
|
||||
bulk-license-cache:
|
||||
30
compose.yaml
30
compose.yaml
@@ -1,8 +1,32 @@
|
||||
services:
|
||||
app:
|
||||
image: git.send.nrw/sendnrw/ai-disclosure-standard:latest
|
||||
env_file:
|
||||
- .env
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: "2.0.2"
|
||||
image: ai-disclosure-standard:2.0.2-local
|
||||
environment:
|
||||
BASE_URL: "${BASE_URL:-http://localhost:8080}"
|
||||
PUBLIC_NAME: "${PUBLIC_NAME:-AI Usage Disclosure}"
|
||||
CONTACT_URL: "${CONTACT_URL:-https://b1tsblog.org/page/ai}"
|
||||
DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}"
|
||||
TRUST_PROXY: "${TRUST_PROXY:-false}"
|
||||
SERVICE_MODE: "${SERVICE_MODE:-full}"
|
||||
API_ALLOWED_ORIGIN: "${API_ALLOWED_ORIGIN:-*}"
|
||||
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}"
|
||||
REQUIRE_LICENSE: "${REQUIRE_LICENSE:-false}"
|
||||
WHITE_LABEL: "${WHITE_LABEL:-false}"
|
||||
BULK_MAX_ITEMS: "${BULK_MAX_ITEMS:-500}"
|
||||
BULK_MAX_BODY_BYTES: "${BULK_MAX_BODY_BYTES:-4194304}"
|
||||
BULK_REQUIRE_API_KEY: "${BULK_REQUIRE_API_KEY:-false}"
|
||||
BULK_API_KEY: "${BULK_API_KEY:-}"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
|
||||
178
deploy/kubernetes-bulk.yaml
Normal file
178
deploy/kubernetes-bulk.yaml
Normal file
@@ -0,0 +1,178 @@
|
||||
# Dedicated licensed bulk service. Keep the Service internal unless an authenticated
|
||||
# API gateway is placed in front of it.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ai-disclosure-bulk
|
||||
labels:
|
||||
app.kubernetes.io/name: ai-disclosure-bulk
|
||||
app.kubernetes.io/version: "2.0.2"
|
||||
spec:
|
||||
replicas: 3
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ai-disclosure-bulk
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ai-disclosure-bulk
|
||||
app.kubernetes.io/version: "2.0.2"
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: /metrics
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: bulk
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.2
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
env:
|
||||
- name: BASE_URL
|
||||
value: http://ai-disclosure-bulk
|
||||
- name: OUTPUT_BASE_URL
|
||||
value: https://ai.example.org
|
||||
- name: DEFAULT_LANGUAGE
|
||||
value: de
|
||||
- name: SERVICE_MODE
|
||||
value: bulk
|
||||
- name: REQUIRE_LICENSE
|
||||
value: "true"
|
||||
- name: API_ALLOWED_ORIGIN
|
||||
value: ""
|
||||
- name: LICENSE_TOKEN_FILE
|
||||
value: /run/secrets/license/token
|
||||
- name: LICENSE_MODE
|
||||
value: hybrid
|
||||
- name: LICENSE_SERVER_URL
|
||||
value: https://licenses.example.org
|
||||
- name: LICENSE_INSTANCE_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.uid
|
||||
- name: LICENSE_CACHE_FILE
|
||||
value: /data/license-lease.json
|
||||
- name: BULK_REQUIRE_API_KEY
|
||||
value: "true"
|
||||
- name: BULK_API_KEY_FILE
|
||||
value: /run/secrets/bulk/key
|
||||
- name: BULK_MAX_ITEMS
|
||||
value: "500"
|
||||
- name: BULK_MAX_BODY_BYTES
|
||||
value: "4194304"
|
||||
volumeMounts:
|
||||
- name: license-cache
|
||||
mountPath: /data
|
||||
- name: license-token
|
||||
mountPath: /run/secrets/license
|
||||
readOnly: true
|
||||
- name: bulk-api-key
|
||||
mountPath: /run/secrets/bulk
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 192Mi
|
||||
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: {}
|
||||
- name: license-token
|
||||
secret:
|
||||
secretName: ai-disclosure-commercial
|
||||
items:
|
||||
- key: license-token
|
||||
path: token
|
||||
- name: bulk-api-key
|
||||
secret:
|
||||
secretName: ai-disclosure-commercial
|
||||
items:
|
||||
- key: bulk-api-key
|
||||
path: key
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ai-disclosure-bulk
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ai-disclosure-bulk
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: ai-disclosure-bulk
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: ai-disclosure-bulk
|
||||
spec:
|
||||
minAvailable: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ai-disclosure-bulk
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: ai-disclosure-bulk
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: ai-disclosure-bulk
|
||||
minReplicas: 3
|
||||
maxReplicas: 20
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 65
|
||||
@@ -4,6 +4,7 @@ metadata:
|
||||
name: ai-disclosure
|
||||
labels:
|
||||
app.kubernetes.io/name: ai-disclosure
|
||||
app.kubernetes.io/version: "2.0.2"
|
||||
spec:
|
||||
replicas: 3
|
||||
strategy:
|
||||
@@ -18,14 +19,20 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ai-disclosure
|
||||
app.kubernetes.io/version: "2.0.2"
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: /metrics
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: app
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.3
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.2
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
@@ -37,42 +44,18 @@ spec:
|
||||
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: "false"
|
||||
- name: LOG_CLIENT_IP
|
||||
value: "false"
|
||||
- name: ENABLE_HSTS
|
||||
value: "true"
|
||||
- name: METRICS_ENABLED
|
||||
value: "false"
|
||||
# Replace the following values before deployment. LEGAL_STRICT
|
||||
# deliberately prevents accidental public startup with placeholders.
|
||||
- name: LEGAL_STRICT
|
||||
value: "true"
|
||||
- name: LEGAL_NAME
|
||||
value: REPLACE_ME
|
||||
- name: LEGAL_ADDRESS
|
||||
value: REPLACE_ME
|
||||
- name: LEGAL_EMAIL
|
||||
value: REPLACE_ME
|
||||
- name: HOSTING_PROVIDER
|
||||
value: REPLACE_ME
|
||||
- name: LOG_RETENTION
|
||||
value: REPLACE_ME
|
||||
- name: CONSUMER_DISPUTE_STATUS
|
||||
value: REPLACE_ME
|
||||
- name: LICENSE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ai-disclosure-license
|
||||
key: token
|
||||
optional: true
|
||||
- name: SERVICE_MODE
|
||||
value: full
|
||||
- name: API_ALLOWED_ORIGIN
|
||||
value: "*"
|
||||
- name: LICENSE_TOKEN_FILE
|
||||
value: /run/secrets/license/token
|
||||
- name: LICENSE_MODE
|
||||
value: offline
|
||||
value: hybrid
|
||||
- name: LICENSE_SERVER_URL
|
||||
value: ""
|
||||
- name: LICENSE_INSTANCE_ID
|
||||
@@ -81,13 +64,18 @@ spec:
|
||||
fieldPath: metadata.uid
|
||||
- name: LICENSE_CACHE_FILE
|
||||
value: /data/license-lease.json
|
||||
- name: WHITE_LABEL
|
||||
value: "false"
|
||||
volumeMounts:
|
||||
- name: license-cache
|
||||
mountPath: /data
|
||||
- name: license-token
|
||||
mountPath: /run/secrets/license
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 24Mi
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 128Mi
|
||||
@@ -115,6 +103,13 @@ spec:
|
||||
volumes:
|
||||
- name: license-cache
|
||||
emptyDir: {}
|
||||
- name: license-token
|
||||
secret:
|
||||
secretName: ai-disclosure-license
|
||||
optional: true
|
||||
items:
|
||||
- key: token
|
||||
path: token
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
61
deploy/swarm-bulk-stack.yaml
Normal file
61
deploy/swarm-bulk-stack.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
bulk:
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard-bulk:2.0.2
|
||||
environment:
|
||||
BASE_URL: http://bulk:8080
|
||||
OUTPUT_BASE_URL: https://ai.example.org
|
||||
DEFAULT_LANGUAGE: de
|
||||
SERVICE_MODE: bulk
|
||||
API_ALLOWED_ORIGIN: ""
|
||||
LICENSE_TOKEN_FILE: /run/secrets/license_token
|
||||
LICENSE_MODE: "${LICENSE_MODE:-hybrid}"
|
||||
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-https://licenses.example.org}"
|
||||
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-swarm-bulk}"
|
||||
LICENSE_CACHE_FILE: /data/license-lease.json
|
||||
REQUIRE_LICENSE: "true"
|
||||
BULK_REQUIRE_API_KEY: "true"
|
||||
BULK_API_KEY_FILE: /run/secrets/bulk_api_key
|
||||
BULK_MAX_ITEMS: "${BULK_MAX_ITEMS:-500}"
|
||||
BULK_MAX_BODY_BYTES: "${BULK_MAX_BODY_BYTES:-4194304}"
|
||||
secrets:
|
||||
- license_token
|
||||
- bulk_api_key
|
||||
networks: [internal]
|
||||
volumes:
|
||||
- bulk-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:
|
||||
replicas: 3
|
||||
update_config:
|
||||
order: start-first
|
||||
parallelism: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
resources:
|
||||
reservations:
|
||||
cpus: "0.05"
|
||||
memory: 32M
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 192M
|
||||
secrets:
|
||||
license_token:
|
||||
external: true
|
||||
bulk_api_key:
|
||||
external: true
|
||||
networks:
|
||||
internal:
|
||||
driver: overlay
|
||||
attachable: false
|
||||
volumes:
|
||||
bulk-license-cache:
|
||||
@@ -1,43 +1,33 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.3
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:2.0.2
|
||||
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: "false"
|
||||
LOG_CLIENT_IP: "false"
|
||||
ENABLE_HSTS: "true"
|
||||
METRICS_ENABLED: "false"
|
||||
LEGAL_STRICT: "${LEGAL_STRICT:-true}"
|
||||
LEGAL_NAME: "${LEGAL_NAME:-REPLACE_ME}"
|
||||
LEGAL_ADDRESS: "${LEGAL_ADDRESS:-REPLACE_ME}"
|
||||
LEGAL_EMAIL: "${LEGAL_EMAIL:-REPLACE_ME}"
|
||||
HOSTING_PROVIDER: "${HOSTING_PROVIDER:-REPLACE_ME}"
|
||||
LOG_RETENTION: "${LOG_RETENTION:-REPLACE_ME}"
|
||||
CONSUMER_DISPUTE_STATUS: "${CONSUMER_DISPUTE_STATUS:-REPLACE_ME}"
|
||||
TRUST_PROXY: "true"
|
||||
SERVICE_MODE: full
|
||||
API_ALLOWED_ORIGIN: "*"
|
||||
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
|
||||
WHITE_LABEL: "${WHITE_LABEL:-false}"
|
||||
ports:
|
||||
- target: 8080
|
||||
published: 8080
|
||||
protocol: tcp
|
||||
mode: ingress
|
||||
networks:
|
||||
- public
|
||||
networks: [public]
|
||||
volumes:
|
||||
- license-cache:/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_drop: [ALL]
|
||||
healthcheck:
|
||||
test: ["CMD", "/ai-disclosure", "--healthcheck"]
|
||||
interval: 15s
|
||||
@@ -67,7 +57,7 @@ services:
|
||||
resources:
|
||||
reservations:
|
||||
cpus: "0.05"
|
||||
memory: 24M
|
||||
memory: 32M
|
||||
limits:
|
||||
cpus: "0.50"
|
||||
memory: 128M
|
||||
@@ -75,6 +65,5 @@ networks:
|
||||
public:
|
||||
driver: overlay
|
||||
attachable: true
|
||||
|
||||
volumes:
|
||||
license-cache:
|
||||
|
||||
52
docs/ARTICLE-50-DECISION-SUPPORT.md
Normal file
52
docs/ARTICLE-50-DECISION-SUPPORT.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Article 50 Decision Support
|
||||
|
||||
Die Anwendung bildet Artikel 50 des EU AI Act nicht als binäre „compliant / non-compliant“-Entscheidung ab. Sie erfasst stattdessen Tatsachen und erzeugt daraus eine konservative, nicht bindende technische Einordnung.
|
||||
|
||||
## Erfasste Tatsachen
|
||||
|
||||
`regulatoryContext` kann enthalten:
|
||||
|
||||
```json
|
||||
{
|
||||
"framework": "EU-AI-Act-Article-50",
|
||||
"publicInterestText": true,
|
||||
"deepfake": false,
|
||||
"artisticCreativeSatiricalFictional": false,
|
||||
"substantialHumanReview": true,
|
||||
"editorialResponsibilityConfirmed": true,
|
||||
"firstExposureDisclosure": true,
|
||||
"accessibilityConsidered": true
|
||||
}
|
||||
```
|
||||
|
||||
Zusätzlich kann `editorialResponsibility` Name und URL der verantwortlichen Person oder Organisation dokumentieren.
|
||||
|
||||
## Ergebnis-Codes
|
||||
|
||||
- `not_assessed` — kein regulatorischer Kontext angegeben;
|
||||
- `voluntary_transparency` — kein typischer Deepfake-/Public-Interest-Textfall markiert;
|
||||
- `deepfake_disclosure_relevant` — Deepfake-/realitätsähnlicher Manipulationskontext angegeben;
|
||||
- `public_interest_text_disclosure_relevant` — Text zu öffentlichem Interesse, Ausnahmevoraussetzungen nicht vollständig bestätigt;
|
||||
- `public_interest_text_possible_exemption` — substanzielle menschliche Prüfung/redaktionelle Kontrolle **und** redaktionelle Verantwortung bestätigt;
|
||||
- `multiple_article50_contexts` — mehrere regulatorische Kontexte (z. B. Deepfake-Bild und Public-Interest-Text) liegen gleichzeitig vor. Die einzelnen Befunde bleiben zusätzlich in `findings` erhalten.
|
||||
|
||||
Zusätzliche Warnungen betreffen insbesondere:
|
||||
|
||||
- nicht bestätigte Offenlegung bei erster Exposition;
|
||||
- nicht bestätigte Barrierefreiheitsberücksichtigung;
|
||||
- fehlende redaktionelle Verantwortung trotz angegebener substantieller Prüfung;
|
||||
- angemessene Offenlegungsform bei offensichtlich künstlerischen, kreativen, satirischen oder fiktionalen Werken.
|
||||
|
||||
## Sichtbares Badge
|
||||
|
||||
Bei einem markierten Deepfake- oder relevanten Public-Interest-Textfall verwendet die Standarddarstellung ein deutlicheres amberfarbenes Badge und eine explizitere Beschriftung. Die ausführliche Seite bleibt die zweite Informationsebene.
|
||||
|
||||
## Rechtliche Grenze
|
||||
|
||||
Die Funktion ist eine technische Entscheidungshilfe. Sie ersetzt weder die Prüfung des konkreten Anwendungsfalls noch andere Pflichten des EU AI Act oder sonstiges Unions-/nationales Recht.
|
||||
|
||||
Offizielle Ausgangspunkte:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
|
||||
- https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems
|
||||
- https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mehrsprachige Hintergrundseite
|
||||
|
||||
Version 1.6.3 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
|
||||
Version 2.0.2 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
|
||||
|
||||
## Routen
|
||||
|
||||
|
||||
114
docs/BULK-API.md
Normal file
114
docs/BULK-API.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Bulk API
|
||||
|
||||
Der Bulk-Dienst ist für Publisher, CMS-Integrationen, Agenturen und andere professionelle Workflows gedacht. Er stellt sowohl eine browserbasierte Bulk Workspace als auch die serverseitige API bereit.
|
||||
|
||||
## Capability
|
||||
|
||||
Die Runtime-Lizenz benötigt:
|
||||
|
||||
```text
|
||||
bulk_api
|
||||
```
|
||||
|
||||
Optional kann die Lizenzplattform ein Limit liefern:
|
||||
|
||||
```text
|
||||
bulk_items=250
|
||||
```
|
||||
|
||||
Der effektive Grenzwert ist der kleinere Wert aus Lizenzlimit und `BULK_MAX_ITEMS`.
|
||||
|
||||
## Dedizierter Container
|
||||
|
||||
`Dockerfile.bulk` verwendet dasselbe getestete Go-Binary, setzt aber sichere Betriebsdefaults:
|
||||
|
||||
```text
|
||||
SERVICE_MODE=bulk
|
||||
REQUIRE_LICENSE=true
|
||||
BULK_REQUIRE_API_KEY=true
|
||||
API_ALLOWED_ORIGIN=
|
||||
```
|
||||
|
||||
Der dedizierte Modus enthält bewusst keine öffentliche Generator-, Produkt- oder Hintergrundseite, stellt aber die spezialisierte Bulk Workspace unter `/` und `/bulk` bereit. Die eigentliche Verarbeitung erfolgt weiterhin über denselben authentifizierten API-Endpunkt.
|
||||
|
||||
## Bulk Workspace
|
||||
|
||||
Die browserbasierte Oberfläche ist erreichbar unter:
|
||||
|
||||
```text
|
||||
/
|
||||
/bulk
|
||||
```
|
||||
|
||||
Sie bietet einen visuellen Mehrfach-Workflow, einen erweiterten JSON-Modus, Ergebnislinks sowie JSON-/CSV-Export. Details stehen in [`BULK-WORKSPACE.md`](BULK-WORKSPACE.md).
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Bei aktivierter API-Key-Pflicht:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <key>
|
||||
```
|
||||
|
||||
oder:
|
||||
|
||||
```http
|
||||
X-API-Key: <key>
|
||||
```
|
||||
|
||||
Der Schlüssel kann über `BULK_API_KEY_FILE` aus einem Container-/Kubernetes-Secret gelesen werden.
|
||||
|
||||
## Anfrage
|
||||
|
||||
```http
|
||||
POST /v1/bulk/declarations
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer ...
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "post-1001",
|
||||
"parameters": {
|
||||
"mode": "article",
|
||||
"lang": "de",
|
||||
"textExtent": "none",
|
||||
"textReview": "none",
|
||||
"coverImageExtent": "full",
|
||||
"coverImageReview": "editorial",
|
||||
"researchExtent": "assisted",
|
||||
"researchReview": "expert",
|
||||
"assurance": "technicallyRecorded"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Jedes Element wird unabhängig verarbeitet. Ein fachlich fehlerhaftes Element führt zu einem Item-Fehler, ohne die übrigen Elemente zu verwerfen.
|
||||
|
||||
## Betriebsgrenzen
|
||||
|
||||
- `BULK_MAX_ITEMS` begrenzt Items pro Request;
|
||||
- `BULK_MAX_BODY_BYTES` begrenzt die Requestgröße;
|
||||
- ein Lizenzlimit `bulk_items` kann die Itemzahl weiter reduzieren;
|
||||
- der Dedicated Container wird ohne gültige `bulk_api`-Capability nicht ready;
|
||||
- bei `REQUIRE_LICENSE=true` werden fachliche Requests ohne gültige Runtime-Lizenz mit 503 blockiert;
|
||||
- Prometheus exportiert Bulk-Request-, Item- und Failure-Counter.
|
||||
|
||||
## Netzwerk
|
||||
|
||||
Der Bulk-Service sollte standardmäßig intern betrieben werden. Die Kubernetes-Vorlage liefert nur einen `ClusterIP`-Service und bewusst keinen Ingress. Bei externer Veröffentlichung empfiehlt sich zusätzlich ein API-Gateway mit TLS, Rate-Limiting und organisationsspezifischer Authentifizierung.
|
||||
|
||||
## Getrennte Runtime- und Ausgabe-URL
|
||||
|
||||
Bei einer separaten Bulk-Instanz kann `BASE_URL` die interne bzw. lizenzgebundene Adresse des Bulk-Dienstes sein, während `OUTPUT_BASE_URL` auf die öffentliche Full-/API-Instanz zeigt:
|
||||
|
||||
```env
|
||||
BASE_URL=http://ai-disclosure-bulk
|
||||
OUTPUT_BASE_URL=https://ai.example.org
|
||||
```
|
||||
|
||||
Dadurch enthalten Bulk-Ergebnisse nutzbare öffentliche Deklarations-, Manifest- und Badge-URLs.
|
||||
69
docs/BULK-WORKSPACE.md
Normal file
69
docs/BULK-WORKSPACE.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Bulk Workspace
|
||||
|
||||
Der dedizierte Bulk-Modus stellt neben `POST /v1/bulk/declarations` wieder eine browserbasierte Arbeitsoberfläche bereit.
|
||||
|
||||
## Routen
|
||||
|
||||
```text
|
||||
GET /
|
||||
GET /bulk
|
||||
POST /v1/bulk/declarations
|
||||
```
|
||||
|
||||
Die Oberfläche ist für interne Publisher-, Agentur- und Enterprise-Workflows gedacht. Sie ersetzt die API nicht, sondern verwendet denselben Endpunkt.
|
||||
|
||||
## Visueller Workflow
|
||||
|
||||
Im Standardworkflow werden Inhalte zeilenweise angegeben:
|
||||
|
||||
```text
|
||||
article-1001 | https://example.org/articles/1001
|
||||
article-1002 | https://example.org/articles/1002
|
||||
```
|
||||
|
||||
Alternativ genügt eine URL pro Zeile. IDs werden dann automatisch erzeugt.
|
||||
|
||||
Ein gemeinsames Artikelprofil kann u. a. festlegen:
|
||||
|
||||
- Text, Titelbild, weitere Bilder, Recherche, Übersetzung und Code;
|
||||
- KI-Anteil und menschliche Prüfung pro Bereich;
|
||||
- Nachweisgrundlage;
|
||||
- relevante Artikel-50-Angaben;
|
||||
- redaktionelle Verantwortung.
|
||||
|
||||
## Erweiterter JSON-Modus
|
||||
|
||||
Heterogene Daten können als regulärer Bulk-Request eingefügt werden. Dadurch können einzelne Artikel vollständig unterschiedliche Parameter erhalten.
|
||||
|
||||
## API-Key
|
||||
|
||||
Ist `BULK_REQUIRE_API_KEY=true`, fragt die Oberfläche den Schlüssel lokal ab. Er wird nur im `sessionStorage` des aktuellen Browser-Tabs gehalten und als `Authorization: Bearer ...` an den Bulk-Endpunkt gesendet.
|
||||
|
||||
Der Schlüssel wird nicht serverseitig in HTML eingebettet und nicht in Exportdateien übernommen.
|
||||
|
||||
## Ergebnisse
|
||||
|
||||
Die Oberfläche zeigt pro Datensatz:
|
||||
|
||||
- ID;
|
||||
- Validierungsstatus;
|
||||
- technische Artikel-50-Einordnung;
|
||||
- Link zur Erklärung;
|
||||
- Link zum JSON-LD-Manifest;
|
||||
- Badge-Link;
|
||||
- Validierungsfehler.
|
||||
|
||||
Das Gesamtergebnis kann als JSON oder CSV exportiert werden.
|
||||
|
||||
## Sicherheitsmodell
|
||||
|
||||
Der dedizierte Container behält die 2.0-Sicherheitsdefaults:
|
||||
|
||||
```text
|
||||
SERVICE_MODE=bulk
|
||||
REQUIRE_LICENSE=true
|
||||
BULK_REQUIRE_API_KEY=true
|
||||
API_ALLOWED_ORIGIN=
|
||||
```
|
||||
|
||||
Die Oberfläche ist deshalb kein Ersatz für Netzwerksegmentierung. Für externe Veröffentlichung sollte weiterhin ein TLS-Reverse-Proxy bzw. API-Gateway mit zusätzlicher Authentifizierung eingesetzt werden.
|
||||
88
docs/COMMERCIAL-DEPLOYMENT.md
Normal file
88
docs/COMMERCIAL-DEPLOYMENT.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Commercial Deployment
|
||||
|
||||
Diese Datei beschreibt die technische Härtung der offiziellen, lizenzierten Distribution. Sie ist keine Vertrags- oder Lizenzvorlage.
|
||||
|
||||
## Runtime-Secrets
|
||||
|
||||
Bevorzugt werden Datei-Secrets:
|
||||
|
||||
```env
|
||||
LICENSE_TOKEN_FILE=/run/secrets/license/token
|
||||
BULK_API_KEY_FILE=/run/secrets/bulk/key
|
||||
```
|
||||
|
||||
statt Secrets direkt in Prozesslisten, Compose-Dateien oder Git-Repositories zu hinterlegen.
|
||||
|
||||
## Lizenzmodi
|
||||
|
||||
- `offline`: vollständig lokale Signaturprüfung;
|
||||
- `hybrid`: zentrale Prüfung mit signiertem Lease-Cache und Offline-Grace-Periode;
|
||||
- `online`: aktuelle zentrale Prüfung erforderlich.
|
||||
|
||||
Für hochverfügbare Publisher-Installationen ist `hybrid` meist der geeignetste technische Kompromiss. Für einen streng kontrollierten, zentral widerrufbaren Dienst kann `online` sinnvoll sein.
|
||||
|
||||
## Full-/API-Container
|
||||
|
||||
Ein lizenziertes Full-Deployment kann beispielsweise setzen:
|
||||
|
||||
```env
|
||||
REQUIRE_LICENSE=true
|
||||
LICENSE_MODE=hybrid
|
||||
WHITE_LABEL=true
|
||||
```
|
||||
|
||||
`WHITE_LABEL=true` wirkt nur mit der Capability `white_label`.
|
||||
|
||||
## Bulk
|
||||
|
||||
Der Bulk-Dienst sollte separat skaliert werden. Er benötigt:
|
||||
|
||||
```text
|
||||
bulk_api
|
||||
```
|
||||
|
||||
und sollte mit `BULK_REQUIRE_API_KEY=true` betrieben werden. Das mitgelieferte `Dockerfile.bulk` erzwingt diese Defaults bereits.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
`deploy/kubernetes-bulk.yaml` enthält:
|
||||
|
||||
- 3 Replikate;
|
||||
- Rolling Update ohne geplante Unterbrechung;
|
||||
- PodDisruptionBudget;
|
||||
- HPA;
|
||||
- Topology Spread;
|
||||
- non-root;
|
||||
- read-only root filesystem;
|
||||
- seccomp `RuntimeDefault`;
|
||||
- keine Linux-Capabilities;
|
||||
- deaktiviertes ServiceAccount-Token;
|
||||
- Lizenz- und API-Key-Secrets als Dateien;
|
||||
- internen ClusterIP-Service;
|
||||
- keinen öffentlichen Ingress.
|
||||
|
||||
## CORS
|
||||
|
||||
`API_ALLOWED_ORIGIN=*` ist für offen konsumierbare Badge-/Manifest-APIs bequem. Für interne kommerzielle APIs kann CORS vollständig deaktiviert werden:
|
||||
|
||||
```env
|
||||
API_ALLOWED_ORIGIN=
|
||||
```
|
||||
|
||||
oder auf einen kontrollierten Origin beschränkt werden.
|
||||
|
||||
## Observability
|
||||
|
||||
- `X-Request-ID` wird übernommen oder erzeugt;
|
||||
- Requests werden strukturiert als JSON protokolliert;
|
||||
- `/metrics` liefert Prometheus-Metriken;
|
||||
- `/healthz` ist reine Liveness;
|
||||
- `/readyz` berücksichtigt bei Bulk die Lizenz-Capability und die notwendige API-Key-Konfiguration.
|
||||
|
||||
## Supply Chain
|
||||
|
||||
Die GitHub-Actions-Vorlage baut Full- und Bulk-Images für amd64/arm64, erzeugt BuildKit-Provenance und SBOMs und signiert den veröffentlichten Multi-Arch-Digest keyless mit Sigstore/Cosign über GitHub OIDC. Produktive Deployments sollten freigegebene Digests statt ausschließlich `latest` verwenden und die Signatur vor dem Rollout prüfen. Siehe [`SUPPLY-CHAIN.md`](SUPPLY-CHAIN.md).
|
||||
|
||||
## Getrennte Bulk-Topologie
|
||||
|
||||
In einer professionellen Topologie sollte der interne Bulk-Dienst `BASE_URL` für seine eigene Instanz-/Domainbindung verwenden und `OUTPUT_BASE_URL` auf die öffentlich erreichbare Deklarationsinstanz setzen. So bleiben erzeugte Links gültig, obwohl `SERVICE_MODE=bulk` selbst keine HTML-Deklarationen oder Badge-Endpunkte bereitstellt.
|
||||
94
docs/CONFIGURATION.md
Normal file
94
docs/CONFIGURATION.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Runtime configuration
|
||||
|
||||
`.env.example` is the canonical and complete reference for environment-based runtime configuration in version 2.0.2.
|
||||
|
||||
The application deliberately does **not** support the former `SALES_URL` or `PRICE_*` variables. Pricing and Pro advertising were removed from the public UI; dead configuration keys are not kept for compatibility.
|
||||
|
||||
## HTTP and identity
|
||||
|
||||
- `LISTEN_ADDRESS`
|
||||
- `BASE_URL`
|
||||
- `OUTPUT_BASE_URL`
|
||||
- `PUBLIC_NAME`
|
||||
- `CONTACT_URL`
|
||||
- `DEFAULT_LANGUAGE`
|
||||
- `HEALTHCHECK_URL`
|
||||
- `SERVICE_MODE`
|
||||
- `API_ALLOWED_ORIGIN`
|
||||
|
||||
## Proxy, logging and transport security
|
||||
|
||||
- `TRUST_PROXY`
|
||||
- `TRUSTED_PROXY_CIDRS`
|
||||
- `LOG_CLIENT_IP`
|
||||
- `ENABLE_HSTS`
|
||||
- `REQUIRE_LICENSE`
|
||||
- `WHITE_LABEL`
|
||||
|
||||
`TRUST_PROXY=true` is rejected unless at least one valid CIDR is configured. Forwarded client-IP headers are only considered when the direct peer address is inside one of those networks.
|
||||
|
||||
`LOG_CLIENT_IP=false` keeps client addresses out of structured request logs.
|
||||
|
||||
`ENABLE_HSTS=true` emits `Strict-Transport-Security: max-age=31536000; includeSubDomains`; enable it only on deployments that are actually HTTPS-only.
|
||||
|
||||
## Prometheus
|
||||
|
||||
- `METRICS_ENABLED`
|
||||
- `METRICS_TOKEN`
|
||||
- `METRICS_TOKEN_FILE`
|
||||
|
||||
Metrics are disabled by default. When enabled, a bearer token of at least 32 characters is mandatory. A configured `METRICS_TOKEN_FILE` has priority over `METRICS_TOKEN`.
|
||||
|
||||
## Operator, privacy and accessibility pages
|
||||
|
||||
The following settings feed `/legal`, `/privacy` and `/accessibility` in `full` and `bulk` mode:
|
||||
|
||||
- `LEGAL_STRICT`
|
||||
- `LEGAL_NAME`
|
||||
- `LEGAL_ADDRESS`
|
||||
- `LEGAL_REPRESENTATIVE`
|
||||
- `LEGAL_EMAIL`
|
||||
- `LEGAL_PHONE`
|
||||
- `LEGAL_REGISTER`
|
||||
- `LEGAL_REGISTER_NUMBER`
|
||||
- `LEGAL_VAT_ID`
|
||||
- `EDITORIAL_RESPONSIBLE_NAME`
|
||||
- `EDITORIAL_RESPONSIBLE_ADDRESS`
|
||||
- `DATA_PROTECTION_CONTACT`
|
||||
- `HOSTING_PROVIDER`
|
||||
- `HOSTING_ADDRESS`
|
||||
- `LOG_RETENTION`
|
||||
- `DATA_RECIPIENTS`
|
||||
- `THIRD_COUNTRY_TRANSFERS`
|
||||
- `SUPERVISORY_AUTHORITY_NAME`
|
||||
- `SUPERVISORY_AUTHORITY_URL`
|
||||
- `CONSUMER_DISPUTE_STATUS`
|
||||
- `CONSUMER_DISPUTE_BODY`
|
||||
- `CONSUMER_DISPUTE_URL`
|
||||
- `ACCESSIBILITY_CONTACT`
|
||||
- `ACCESSIBILITY_STATUS`
|
||||
|
||||
`LEGAL_STRICT=true` is a deployment guard, not a legal-compliance determination. It prevents startup if the core operator/privacy data is empty or still contains common placeholder markers. The pages themselves explicitly state that they do not replace legal review.
|
||||
|
||||
## Runtime licensing
|
||||
|
||||
- `LICENSE_TOKEN`
|
||||
- `LICENSE_TOKEN_FILE`
|
||||
- `LICENSE_INSTANCE_ID`
|
||||
- `LICENSE_MODE`
|
||||
- `LICENSE_SERVER_URL`
|
||||
- `LICENSE_CACHE_FILE`
|
||||
- `LICENSE_REFRESH_INTERVAL`
|
||||
- `LICENSE_REQUEST_TIMEOUT`
|
||||
|
||||
The application contains no licence issuer, private signing key or configurable issuer public key. Licence creation remains external in the Universal License Platform.
|
||||
|
||||
## Bulk
|
||||
|
||||
- `BULK_MAX_ITEMS`
|
||||
- `BULK_MAX_BODY_BYTES`
|
||||
- `BULK_REQUIRE_API_KEY`
|
||||
- `BULK_API_KEY`
|
||||
- `BULK_API_KEY_FILE`
|
||||
|
||||
The `bulk_items` licence limit can further reduce the effective item count. A configured `BULK_API_KEY_FILE` has priority over `BULK_API_KEY`.
|
||||
@@ -1,157 +0,0 @@
|
||||
# EU AI Act: Compliance-Grenzen und Betreiber-Checkliste
|
||||
|
||||
**Rechtsstand: 21. Juli 2026**
|
||||
|
||||
Diese Datei beschreibt, welche Teile des EU AI Act (Verordnung (EU) 2024/1689) dieses Projekt technisch unterstützen kann und welche Pflichten außerhalb seines Funktionsumfangs liegen. Sie ersetzt keine Prüfung des konkreten Einsatzes.
|
||||
|
||||
## 1. Was dieses Projekt ist
|
||||
|
||||
Die mitgelieferte Anwendung erzeugt deterministisch SVG-Hinweise, Erklärungsseiten und JSON-LD aus den vom Nutzer eingegebenen Angaben. Sie enthält selbst kein KI-Modell und führt keine Modellinferenz aus.
|
||||
|
||||
Der AI Act erfasst nur Systeme, die unter die Definition eines „AI system“ in Artikel 3 Absatz 1 fallen. Ob eine konkrete, veränderte oder erweiterte Installation diese Definition erfüllt, ist anhand der tatsächlichen technischen Funktion zu prüfen.
|
||||
|
||||
Offizielle Quelle:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-3
|
||||
|
||||
## 2. Dieses Projekt ist keine vollständige AI-Act-Compliance-Lösung
|
||||
|
||||
Der Generator unterstützt vor allem die **sichtbare und dokumentierte Offenlegung von KI-Nutzung**. Er kann insbesondere bei freiwilliger Transparenz und bei bestimmten Betreiberpflichten aus Artikel 50 Absatz 4 helfen.
|
||||
|
||||
Er erfüllt oder prüft **nicht automatisch**:
|
||||
|
||||
- die Anbieterpflicht zur technischen, maschinenlesbaren Markierung von generierten oder manipulierten Ausgaben nach Artikel 50 Absatz 2;
|
||||
- die Transparenzpflicht bei direkter Mensch-KI-Interaktion nach Artikel 50 Absatz 1;
|
||||
- Informationspflichten bei Emotionserkennung oder biometrischer Kategorisierung nach Artikel 50 Absatz 3;
|
||||
- die Pflicht zur AI Literacy nach Artikel 4;
|
||||
- Verbote nach Artikel 5;
|
||||
- die Einstufung und Pflichten für Hochrisiko-KI nach Kapitel III;
|
||||
- Pflichten für Anbieter von General-Purpose-AI-Modellen nach Kapitel V;
|
||||
- sonstige Pflichten aus Datenschutz-, Urheber-, Verbraucher-, Medien- oder Produktsicherheitsrecht.
|
||||
|
||||
## 3. Artikel 50: sichtbare Offenlegung durch Betreiber
|
||||
|
||||
Artikel 50 gilt ab **2. August 2026**. Betreiber bestimmter generativer KI-Systeme müssen insbesondere Deepfakes und bestimmte KI-generierte oder manipulierte Texte zu Angelegenheiten von öffentlichem Interesse offenlegen.
|
||||
|
||||
Für solche sichtbaren Offenlegungen gilt nach Artikel 50 Absatz 5 und den Leitlinien der Kommission insbesondere:
|
||||
|
||||
1. Der Hinweis muss **klar und unterscheidbar** sein.
|
||||
2. Er muss **spätestens bei der ersten Exposition** gegenüber der betroffenen natürlichen Person erscheinen.
|
||||
3. Er muss die geltenden Anforderungen an **Barrierefreiheit** berücksichtigen.
|
||||
4. Bei Deepfakes genügt eine nur maschinenlesbare Markierung des Anbieters nicht als sichtbare Betreiber-Offenlegung.
|
||||
|
||||
Daher gilt für die Einbettung dieses Projekts:
|
||||
|
||||
- Badge oder Klartext unmittelbar am betroffenen Inhalt platzieren;
|
||||
- nicht ausschließlich im Footer, Impressum oder auf einer erst später erreichbaren Unterseite;
|
||||
- aussagekräftigen Alternativtext verwenden;
|
||||
- bei der quadratischen Emoji-Variante zusätzlich einen unmittelbar verständlichen Text oder eine gleichwertig eindeutige sichtbare Kennzeichnung verwenden.
|
||||
|
||||
Offizielle Quellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
|
||||
- https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
|
||||
- https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content
|
||||
|
||||
## 4. Menschliche Prüfung und redaktionelle Kontrolle
|
||||
|
||||
Für bestimmte veröffentlichte KI-generierte oder manipulierte Texte zu Angelegenheiten von öffentlichem Interesse kann die Kennzeichnungspflicht nach Artikel 50 Absatz 4 entfallen, wenn die gesetzlichen Voraussetzungen tatsächlich erfüllt sind.
|
||||
|
||||
Die Kommission stellt klar:
|
||||
|
||||
- „human review“ erfordert eine bewusste inhaltliche Prüfung durch Personen mit einschlägigem Wissen und professionellem Urteil;
|
||||
- „editorial control“ setzt tatsächliche inhaltliche Entscheidungsbefugnis voraus, also insbesondere die Möglichkeit, Inhalte aus sachlichen Gründen zu genehmigen, zu ändern oder abzulehnen;
|
||||
- rein oberflächliche, formale oder prozedurale Prüfungen wie Rechtschreib- oder Grammatikprüfung genügen nicht;
|
||||
- zusätzlich muss eine natürliche oder juristische Person die redaktionelle Verantwortung für die Veröffentlichung tragen.
|
||||
|
||||
Aus diesem Grund setzt der Generator seit dieser Härtung **keine menschliche oder redaktionelle Prüfung mehr automatisch voraus**. Der Nutzer muss den tatsächlich durchgeführten Prozess ausdrücklich auswählen.
|
||||
|
||||
Die Felder `editorialResponsibility.name` und `editorialResponsibility.url` dienen nur der Dokumentation. Ihre Angabe beweist nicht automatisch, dass die gesetzlichen Voraussetzungen vorliegen.
|
||||
|
||||
## 5. Artikel 50 Absatz 2: JSON-LD ist kein Ersatz für Provider-Marking
|
||||
|
||||
Anbieter generativer KI-Systeme müssen in den erfassten Fällen dafür sorgen, dass generierte oder manipulierte Inhalte maschinenlesbar markiert und als künstlich generiert oder manipuliert erkennbar sind. Nach Artikel 50 Absatz 2 müssen die technischen Lösungen wirksam, interoperabel, robust und zuverlässig sein, soweit dies technisch machbar ist.
|
||||
|
||||
Das von diesem Projekt erzeugte **JSON-LD ist ergänzende Dokumentation**. Es ist nicht als Ersatz für die technische Markierung gedacht, die der Anbieter des generativen KI-Systems in oder an der Ausgabe implementieren muss.
|
||||
|
||||
Wer selbst Anbieter eines generativen KI-Systems im Sinne des AI Act ist, muss deshalb zusätzlich eine geeignete Provider-Marking-Lösung umsetzen und dokumentieren. Der von der Kommission positiv bewertete Code of Practice kann hierfür als freiwilliger Compliance-Rahmen genutzt werden.
|
||||
|
||||
Offizielle Quellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
|
||||
- https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content
|
||||
- https://digital-strategy.ec.europa.eu/en/library/commission-opinion-assessment-code-practice-transparency-ai-generated-content
|
||||
|
||||
## 6. Artikel 4: AI Literacy
|
||||
|
||||
Artikel 4 gilt bereits seit **2. Februar 2025**. Anbieter und Betreiber von KI-Systemen müssen nach besten Kräften Maßnahmen treffen, um ein ausreichendes Maß an AI Literacy bei Mitarbeitenden und sonstigen Personen sicherzustellen, die in ihrem Auftrag mit Betrieb oder Nutzung von KI-Systemen befasst sind.
|
||||
|
||||
Ein Badge oder eine Veröffentlichungserklärung erfüllt diese Organisationspflicht nicht. Betreiber sollten mindestens dokumentieren:
|
||||
|
||||
- welche KI-Systeme eingesetzt werden;
|
||||
- welche Rollen sie bedienen;
|
||||
- welche Kenntnisse und Schulungen erforderlich sind;
|
||||
- welche internen Regeln für Prüfung, Freigabe und Eskalation gelten;
|
||||
- wann Schulungen oder Richtlinien zuletzt aktualisiert wurden.
|
||||
|
||||
Offizielle Quelle:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-4
|
||||
|
||||
## 7. Risiko- und Rollenprüfung vor Produktivnutzung
|
||||
|
||||
Vor dem Einsatz sollte die Organisation dokumentieren:
|
||||
|
||||
1. Ist die eingesetzte Software überhaupt ein KI-System im Sinne von Artikel 3 Absatz 1?
|
||||
2. Welche Rolle liegt vor: Anbieter, Betreiber, Importeur, Distributor oder Produkthersteller?
|
||||
3. Fällt das konkrete KI-System unter ein Verbot des Artikels 5?
|
||||
4. Ist es ein Hochrisiko-KI-System nach Artikel 6 bzw. Anhang III?
|
||||
5. Handelt es sich um ein General-Purpose-AI-Modell oder ein darauf basierendes System mit zusätzlichen Pflichten?
|
||||
6. Greift eine Transparenzpflicht aus Artikel 50?
|
||||
7. Welche anderen Rechtsgebiete sind zusätzlich betroffen, insbesondere DSGVO, Urheberrecht, Verbraucher- und Medienrecht?
|
||||
|
||||
Offizielle Einstiegsquellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-2
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-5
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-6
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/annex-3
|
||||
|
||||
## 8. Zeitlicher Stand
|
||||
|
||||
Nach Artikel 113 gilt der AI Act grundsätzlich ab 2. August 2026; einzelne Teile gelten bereits früher. Insbesondere gelten Kapitel I und II, einschließlich Artikel 4 und der Verbote des Artikels 5, seit 2. Februar 2025.
|
||||
|
||||
Für Artikel 50 beginnen die Transparenzpflichten am 2. August 2026. Die Kommission weist für bestimmte bereits vor diesem Datum in Verkehr gebrachte generative KI-Systeme auf eine begrenzte Übergangsregel für die Anbieter-Markierung nach Artikel 50 Absatz 2 bis 2. Dezember 2026 hin.
|
||||
|
||||
Offizielle Quellen:
|
||||
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-113
|
||||
- https://ai-act-service-desk.ec.europa.eu/en/ai-act/timeline/timeline-implementation-eu-ai-act
|
||||
- https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
|
||||
|
||||
## 9. Praktische Mindestregel für dieses Projekt
|
||||
|
||||
Wenn ein Inhalt **möglicherweise** unter Artikel 50 Absatz 4 fällt, ist die rechtlich defensivere Konfiguration:
|
||||
|
||||
- KI-Nutzung wahrheitsgemäß als `partial`, `mostly` oder `full` angeben;
|
||||
- die konkrete Tätigkeit angeben;
|
||||
- `humanReview` nur dann auf `editorial` oder `expert` setzen, wenn eine tatsächlich substanzielle inhaltliche Prüfung stattgefunden hat;
|
||||
- die redaktionell verantwortliche Person oder Organisation dokumentieren, wenn dies zutrifft;
|
||||
- einen sichtbaren textlichen Hinweis oder ein verständliches Standard-Badge spätestens bei der ersten Exposition platzieren;
|
||||
- das Emoji nicht als alleinige gesetzliche Kennzeichnung verwenden;
|
||||
- das JSON-LD nur als zusätzliche Dokumentation behandeln.
|
||||
|
||||
Bei Zweifeln über die Ausnahme wegen menschlicher Prüfung ist eine **zusätzliche Offenlegung regelmäßig die risikoärmere technische Entscheidung**, solange dadurch keine anderen rechtlichen Pflichten verletzt werden.
|
||||
|
||||
## Strukturierter rechtlicher Kontext in Schema 1.2
|
||||
|
||||
Die Deklaration kann optional `legalContext.categories` enthalten. Unterstützt werden `deepfake`, `publicInterestText`, `artisticCreativeSatiricalFictional` und `otherVoluntary`. Diese Angaben sind ausdrücklich als Selbsteinordnung konzipiert und ersetzen keine rechtliche Subsumtion.
|
||||
|
||||
Der Generator trennt damit zwei Ebenen:
|
||||
|
||||
1. **Erstkennzeichnung am Inhalt:** Bei einer als voraussichtlich offenlegungspflichtig eingeordneten Nutzung wird eine verständliche Beschriftung wie „KI-generierter / manipulierter Inhalt“ oder „KI-generierte / bearbeitete Inhalte“ erzeugt.
|
||||
2. **Zweite Ebene:** Die `/declaration`-Seite dokumentiert KI-Anteil, Tätigkeiten, menschliche Prüfung, Nachweisgrundlage, rechtlichen Kontext und gegebenenfalls redaktionelle Verantwortung.
|
||||
|
||||
Für `publicInterestText` wird eine mögliche Ausnahme nur als Möglichkeit angezeigt, wenn sowohl eine substanzielle Prüfung (`editorial` oder `expert`) als auch eine ausdrücklich benannte redaktionelle Verantwortung dokumentiert sind. Formale Prüfung (`basic`) reicht dafür nicht. Bei `deepfake` bleibt die Einschätzung auf Offenlegung gerichtet; die zusätzliche Kategorie `artisticCreativeSatiricalFictional` weist auf die begrenzte, angemessene Offenlegungsform für evident kreative Werke hin.
|
||||
|
||||
Das System formuliert bewusst „spricht vieles dafür“ beziehungsweise „kann in Betracht kommen“ und gibt keine verbindliche Rechtsentscheidung aus.
|
||||
@@ -1,101 +0,0 @@
|
||||
# EU AI Act Review – Ergänzung vom 21.07.2026
|
||||
|
||||
## Ergebnis
|
||||
|
||||
Die zusätzlichen Anforderungen wurden in Version 1.6.3 / Declaration Schema 1.2 aufgenommen. Das Projekt trennt nun ausdrücklich zwischen freiwilliger KI-Transparenz und einem vom Nutzer selbst angegebenen rechtlichen Kontext für Artikel 50.
|
||||
|
||||
Eine Nutzung des Standards allein begründet weiterhin keine vollständige EU-AI-Act-Konformität. Die konkrete Rolle (Provider/Deployer), das eingesetzte KI-System, der Inhalt, der Veröffentlichungskontext sowie weitere Pflichten des AI Act müssen separat geprüft werden.
|
||||
|
||||
## Neu aufgenommen
|
||||
|
||||
### 1. Strukturierter rechtlicher Kontext
|
||||
|
||||
`legalContext.categories` unterstützt:
|
||||
|
||||
- `deepfake`
|
||||
- `publicInterestText`
|
||||
- `artisticCreativeSatiricalFictional`
|
||||
- `otherVoluntary`
|
||||
|
||||
Die Angaben sind als Selbsteinordnung konzipiert und nicht als automatische rechtliche Subsumtion.
|
||||
|
||||
### 2. Deepfake-Abgrenzung
|
||||
|
||||
Der Generator erklärt, dass nicht jedes KI-generierte Bild ein Deepfake ist. Die Kategorie ist für KI-generierte oder manipulierte Bild-, Audio- oder Videoinhalte vorgesehen, die bestehenden oder plausibel existierenden Personen, Objekten, Orten, Entitäten oder Ereignissen ähneln und fälschlich authentisch oder wahr erscheinen können.
|
||||
|
||||
### 3. Texte zu Angelegenheiten von öffentlichem Interesse
|
||||
|
||||
Für `publicInterestText` berücksichtigt die vorsichtige Einschätzung:
|
||||
|
||||
- ob eine substanzielle menschliche Prüfung oder redaktionelle Kontrolle dokumentiert wurde (`editorial` oder `expert`), und
|
||||
- ob eine redaktionell verantwortliche natürliche oder juristische Person ausdrücklich benannt wurde.
|
||||
|
||||
Formale Prüfung (`basic`) wird nicht als ausreichende inhaltliche Prüfung behandelt.
|
||||
|
||||
### 4. Redaktionelle Verantwortung
|
||||
|
||||
Die Verantwortung wird nun expliziter gespeichert:
|
||||
|
||||
```json
|
||||
{
|
||||
"editorialResponsibility": {
|
||||
"assumed": true,
|
||||
"role": "publisher",
|
||||
"name": "Beispielredaktion",
|
||||
"url": "https://example.org/impressum"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Unterstützte Rollen:
|
||||
|
||||
- `publisher`
|
||||
- `other`
|
||||
|
||||
Eine bloße URL ohne benannte verantwortliche Stelle wird nicht akzeptiert.
|
||||
|
||||
### 5. Zwei Ebenen der Kennzeichnung
|
||||
|
||||
Bei freiwilliger Transparenz kann weiterhin z. B. „Artikeltransparenz“ verwendet werden.
|
||||
|
||||
Wenn die Selbsteinordnung auf einen voraussichtlich kennzeichnungspflichtigen Artikel-50-Fall hindeutet, wird die Erstkennzeichnung klarer formuliert, z. B.:
|
||||
|
||||
- „KI-generierter / manipulierter Inhalt“
|
||||
- „KI-generierte / bearbeitete Inhalte“
|
||||
|
||||
Die ausführliche zweite Ebene bleibt die `/declaration`-Seite.
|
||||
|
||||
### 6. Emoji-Modus
|
||||
|
||||
Der quadratische Emoji-Modus bleibt verfügbar. Bei einem als wahrscheinlich offenlegungspflichtig eingeordneten Fall ergänzt der generierte HTML-/Markdown-Einbettungscode neben dem Emoji zusätzlich sichtbaren Klartext. Das Emoji wird weiterhin nicht als offizielles EU-Icon oder als alleiniger Konformitätsnachweis dargestellt.
|
||||
|
||||
### 7. Custom-Badge-Schutz
|
||||
|
||||
In einem vom Nutzer als wahrscheinlich gesetzlich relevanten Artikel-50-Fall kann ein Pro-Custom-Text die eindeutige Erstkennzeichnung nicht mehr durch eine abstrakte Badge-Botschaft ersetzen. Farben und linke Beschriftung bleiben anpassbar; die entscheidende Offenlegungsbotschaft bleibt verständlich.
|
||||
|
||||
### 8. Vorsichtige Einschätzung auf der Declaration-Seite
|
||||
|
||||
Die Seite zeigt nun eine gesonderte „Vorsichtige Art.-50-Einschätzung“.
|
||||
|
||||
Beispiele:
|
||||
|
||||
- Deepfake: „spricht vieles dafür“, dass eine klare Offenlegung spätestens bei erster Exposition erforderlich ist.
|
||||
- Public-Interest-Text ohne dokumentierte Ausnahmevoraussetzungen: „spricht vieles dafür“, dass eine Offenlegung erfolgen sollte.
|
||||
- Public-Interest-Text mit substantieller Prüfung und ausdrücklich benannter redaktioneller Verantwortung: die Ausnahme „kann in Betracht kommen“.
|
||||
|
||||
Es werden bewusst keine verbindlichen Formulierungen wie „rechtlich zwingend“ oder „AI-Act-konform“ ausgegeben.
|
||||
|
||||
## Aktuelle offizielle Grundlagen
|
||||
|
||||
- Artikel 50 AI Act: https://ai-act-service-desk.ec.europa.eu/en/ai-act/article-50
|
||||
- Kommissionsleitlinien vom 20.07.2026: https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems
|
||||
- FAQ zu Artikel 50: https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
|
||||
- EU-Icons: https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content
|
||||
- Code of Practice: https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content
|
||||
- Kommissionsbewertung des Code of Practice: https://digital-strategy.ec.europa.eu/en/library/commission-opinion-assessment-code-practice-transparency-ai-generated-content
|
||||
|
||||
## Verbleibende rechtliche Grenze
|
||||
|
||||
Der Standard kann Transparenz- und Dokumentationsprozesse unterstützen. Er kann nicht automatisch prüfen oder erfüllen, ob beispielsweise Artikel 4 (AI Literacy), Artikel 5 (verbotene Praktiken), Hochrisiko-Pflichten, GPAI-Pflichten, Datenschutz-, Urheber-, Medien- oder Verbraucherrecht im konkreten Einsatzfall greifen.
|
||||
|
||||
Daher sollte öffentlich weiterhin nicht pauschal mit „EU AI Act compliant“ oder „erfüllt alle Anforderungen des EU AI Act“ geworben werden.
|
||||
@@ -1,148 +0,0 @@
|
||||
# Rechtlicher und sicherer Produktivbetrieb
|
||||
|
||||
Stand: 20. Juli 2026
|
||||
|
||||
Diese Datei beschreibt die mitgelieferten technischen Schutzmaßnahmen und die Punkte, die ein Betreiber vor einer öffentlichen Bereitstellung selbst prüfen und vervollständigen muss. Sie ersetzt keine Rechtsberatung für den konkreten Einzelfall.
|
||||
|
||||
## 1. Vor dem ersten öffentlichen Start
|
||||
|
||||
1. `.env.example` nach `.env` kopieren und alle `REPLACE_ME`-Werte durch zutreffende Angaben ersetzen.
|
||||
2. `LEGAL_STRICT=true` aktiv lassen. Der Dienst verweigert dann den Start, wenn Kernangaben fehlen.
|
||||
3. Impressum, Datenschutzerklärung und Barrierefreiheitserklärung im fertig deployten System prüfen:
|
||||
- `/impressum`
|
||||
- `/datenschutz`
|
||||
- `/barrierefreiheit`
|
||||
4. Tatsächliche Hosting-, Proxy-, CDN-, DNS-, Logging-, Backup-, Monitoring- und Lizenzdienste mit der Datenschutzerklärung abgleichen.
|
||||
5. Bei Verbraucherverträgen prüfen, welche Angaben nach dem Verbraucherstreitbeilegungsgesetz erforderlich sind. Die frühere EU-OS-Plattform wurde eingestellt; ein alter OS-Link sollte nicht übernommen werden.
|
||||
6. Bei journalistisch-redaktionellen Angeboten prüfen, ob ein Verantwortlicher nach § 18 Abs. 2 MStV benannt werden muss.
|
||||
7. Bei Angeboten an Verbraucher prüfen, ob das BFSG und die BFSGV anwendbar sind. Eine bloße Selbsterklärung ersetzt keinen Accessibility-Audit.
|
||||
|
||||
## 2. Anbieterkennzeichnung
|
||||
|
||||
Die Vorlage deckt typische Felder für § 5 DDG und § 18 MStV ab. Je nach Betreiber können weitere Angaben erforderlich sein, etwa:
|
||||
|
||||
- Rechtsform und Vertretungsberechtigte;
|
||||
- Register, Registernummer und Registergericht;
|
||||
- Umsatzsteuer-Identifikationsnummer;
|
||||
- Aufsichtsbehörde und berufsrechtliche Angaben;
|
||||
- redaktionell Verantwortliche mit Name und Anschrift;
|
||||
- Erklärung zur Verbraucherstreitbeilegung.
|
||||
|
||||
Offizielle Grundlagen:
|
||||
|
||||
- § 5 DDG: https://www.gesetze-im-internet.de/ddg/__5.html
|
||||
- § 18 MStV: https://www.gesetze-bayern.de/Content/Document/MStV-18
|
||||
- § 36 VSBG: https://www.gesetze-im-internet.de/vsbg/__36.html
|
||||
|
||||
## 3. Datenschutz
|
||||
|
||||
Die ausgelieferte Weboberfläche verwendet keine Cookies, kein Tracking und keine Browser-Speicher wie Local Storage. Das allein macht einen Betrieb nicht automatisch datenschutzkonform. Relevant sind insbesondere die tatsächlichen Infrastruktur-Logs und zusätzlich eingebundene Dienste.
|
||||
|
||||
Technischer Standardzustand:
|
||||
|
||||
- kein Datenbank- oder Session-Speicher für Generator-Eingaben;
|
||||
- keine Query-Strings in den Anwendungslogs;
|
||||
- keine Client-IP in den Anwendungslogs, solange `LOG_CLIENT_IP=false` bleibt;
|
||||
- URL-Parameter können dennoch in Browser-Verläufen, Reverse-Proxy-, CDN- oder Hosting-Logs erscheinen;
|
||||
- Hybrid- und Online-Lizenzmodi kommunizieren mit dem konfigurierten Lizenzserver;
|
||||
- öffentliche Erklärungs-URLs dürfen keine vertraulichen oder unnötigen personenbezogenen Daten enthalten.
|
||||
|
||||
Der Betreiber muss insbesondere festlegen und umsetzen:
|
||||
|
||||
- Rechtsgrundlagen und Zwecke;
|
||||
- Empfänger und Auftragsverarbeiter;
|
||||
- Lösch- und Aufbewahrungsfristen;
|
||||
- Drittlandübermittlungen;
|
||||
- technisch-organisatorische Maßnahmen;
|
||||
- Prozesse für Betroffenenrechte und Datenschutzvorfälle.
|
||||
|
||||
Offizielle Grundlagen:
|
||||
|
||||
- Art. 13 DSGVO: https://eur-lex.europa.eu/eli/reg/2016/679/oj
|
||||
- § 25 TDDDG: https://www.gesetze-im-internet.de/ttdsg/__25.html
|
||||
|
||||
## 4. Barrierefreiheit
|
||||
|
||||
Die Anwendung nutzt semantische Formulare und Tabellen, sichtbare Fokuszustände, responsive Layouts und Textalternativen. Sie erhebt ohne gesonderten Audit keinen Anspruch auf vollständige WCAG- oder EN-301-549-Konformität.
|
||||
|
||||
Für ein erfasstes Verbraucherangebot sind unter anderem zu prüfen:
|
||||
|
||||
- Tastaturbedienung und Fokusreihenfolge;
|
||||
- Kontraste, Zoom und Reflow;
|
||||
- verständliche Fehlermeldungen;
|
||||
- Screenreader-Ausgabe;
|
||||
- Alternativtexte eingebetteter Badges;
|
||||
- Barrierefreiheit des vollständigen Bestell- oder Vertragspfads;
|
||||
- gesetzlich verlangte Informationen zur Barrierefreiheit.
|
||||
|
||||
Offizielle Informationen: https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz
|
||||
|
||||
## 5. Sicherheitsstandard
|
||||
|
||||
Mitgeliefert werden unter anderem:
|
||||
|
||||
- restriktive Content Security Policy mit zufälliger Nonce je HTML-Antwort;
|
||||
- `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `nosniff`, Referrer- und Permissions-Policy;
|
||||
- HSTS bei HTTPS-Basis-URL;
|
||||
- Größenlimit und striktes JSON-Decoding für den Validator;
|
||||
- Begrenzung paralleler Validierungsanfragen;
|
||||
- validierte und begrenzte Request-IDs;
|
||||
- Proxy-Header nur aus ausdrücklich konfigurierten Proxy-Netzen;
|
||||
- standardmäßig deaktivierte Client-IP-Logs und Prometheus-Metriken;
|
||||
- verpflichtender Bearer-Schutz bei aktiviertem `/metrics`;
|
||||
- Non-Root-Container, schreibgeschütztes Dateisystem, entfernte Linux-Capabilities und `no-new-privileges`.
|
||||
|
||||
Zusätzlich in der Betriebsumgebung erforderlich:
|
||||
|
||||
- TLS-Terminierung und sichere Zertifikatsverwaltung;
|
||||
- Rate Limits am Edge/Ingress;
|
||||
- regelmäßige Image- und Abhängigkeitsupdates;
|
||||
- Secret-Management statt Klartext-Umgebungsvariablen, soweit möglich;
|
||||
- Netzwerksegmentierung für Metriken und Lizenzserver;
|
||||
- zentrale Log-Löschung entsprechend `LOG_RETENTION`;
|
||||
- Backup-, Restore- und Incident-Response-Verfahren;
|
||||
- Überwachung ohne unnötige personenbezogene Telemetrie.
|
||||
|
||||
## 6. Reverse Proxy
|
||||
|
||||
`TRUST_PROXY=true` darf nur zusammen mit `TRUSTED_PROXY_CIDRS` verwendet werden. Trage ausschließlich Netze ein, aus denen dein kontrollierter Reverse Proxy die Anwendung tatsächlich erreicht. Andernfalls könnten Clients weitergeleitete IP-Header vortäuschen.
|
||||
|
||||
Beispiel für einen ausschließlich lokalen Proxy:
|
||||
|
||||
```env
|
||||
TRUST_PROXY=true
|
||||
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||
```
|
||||
|
||||
Docker-, Kubernetes- oder Cloud-Netze unterscheiden sich je Umgebung und dürfen nicht pauschal kopiert werden.
|
||||
|
||||
## 7. Metriken
|
||||
|
||||
`/metrics` ist standardmäßig nicht registriert. Für eine Aktivierung:
|
||||
|
||||
```env
|
||||
METRICS_ENABLED=true
|
||||
METRICS_TOKEN_FILE=/run/secrets/metrics-token
|
||||
```
|
||||
|
||||
Der Bearer-Token schützt den Endpunkt auf Anwendungsebene. Zusätzlich sollte der Endpunkt nicht über den öffentlichen Ingress erreichbar sein.
|
||||
|
||||
## 8. KI-Transparenz und EU AI Act
|
||||
|
||||
Der Standard unterstützt freiwillige Dokumentation und bestimmte Transparenz-Workflows, insbesondere sichtbare Offenlegungen. Er entscheidet nicht automatisch, ob Artikel 50 des AI Act auf einen konkreten Inhalt anwendbar ist. Die offiziellen Transparenzleitlinien der Europäischen Kommission wurden am 20. Juli 2026 veröffentlicht; Artikel 50 gilt ab dem 2. August 2026.
|
||||
|
||||
Wichtige Grenzen:
|
||||
|
||||
- Presets unterstellen keine menschliche oder redaktionelle Prüfung mehr. Ein solcher Prozess muss ausdrücklich angegeben werden.
|
||||
- Eine bloß formale Prüfung wie Rechtschreibung oder Grammatik ist nach den Kommissionsleitlinien keine substantielle menschliche Prüfung oder redaktionelle Kontrolle im Sinne der Ausnahme für bestimmte Texte nach Artikel 50 Absatz 4.
|
||||
- Ein sichtbarer Hinweis muss bei einer einschlägigen Pflicht klar und unterscheidbar spätestens bei der ersten Exposition erscheinen.
|
||||
- Die Emoji-Variante ist nur ein kompakter Zusatz und sollte bei einer gesetzlichen Kennzeichnung nicht ohne verständlichen Text eingesetzt werden.
|
||||
- Das erzeugte JSON-LD ist ergänzende Dokumentation und kein automatischer Ersatz für die Provider-Markierung nach Artikel 50 Absatz 2.
|
||||
- Der AI Act enthält weitere Pflichten außerhalb dieses Projekts, unter anderem AI Literacy nach Artikel 4 sowie gegebenenfalls Verbote, Hochrisiko- oder GPAI-Pflichten.
|
||||
|
||||
Siehe die ausführliche Checkliste in [`EU-AI-ACT-COMPLIANCE.md`](EU-AI-ACT-COMPLIANCE.md).
|
||||
|
||||
- AI Act: https://eur-lex.europa.eu/eli/reg/2024/1689/oj
|
||||
- Leitlinien: https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems
|
||||
- FAQ: https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act
|
||||
- Code of Practice: https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content
|
||||
@@ -16,7 +16,7 @@ Initialization is performed in `internal/app/server.go`. The product ID and embe
|
||||
```go
|
||||
licenses := licenseclient.New(ctx, licenseclient.Config{
|
||||
Product: "ai-disclosure-standard",
|
||||
ClientVersion: "1.6.3",
|
||||
ClientVersion: "2.0.2",
|
||||
Token: cfg.LicenseToken,
|
||||
TrustStore: trustStore,
|
||||
BaseURL: cfg.BaseURL,
|
||||
|
||||
@@ -119,7 +119,7 @@ Anfrage:
|
||||
"baseUrl": "https://ai.example.org",
|
||||
"host": "ai.example.org",
|
||||
"instanceId": "production-eu-1",
|
||||
"clientVersion": "1.6.3"
|
||||
"clientVersion": "2.0.2"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -170,3 +170,23 @@ Unter `third_party/license-platform-client` liegt eine client-only, protokollkom
|
||||
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.
|
||||
|
||||
## Capabilities used by 2.0
|
||||
|
||||
```text
|
||||
custom_text
|
||||
custom_badge
|
||||
white_label
|
||||
bulk_api
|
||||
export_bundle
|
||||
```
|
||||
|
||||
The optional numeric limit `bulk_items` caps the number of items accepted by a single Bulk API request. The application always applies the stricter value of the runtime configuration and the signed licence limit.
|
||||
|
||||
For an official deployment that must fail closed when the licence is unavailable, set:
|
||||
|
||||
```env
|
||||
REQUIRE_LICENSE=true
|
||||
```
|
||||
|
||||
This affects both readiness and ordinary application requests. Liveness, readiness, metrics and capability inspection remain available for operations and diagnosis.
|
||||
|
||||
40
docs/LICENSING-STRATEGY.md
Normal file
40
docs/LICENSING-STRATEGY.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Licensing Strategy
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
Der Quellstand enthält derzeit die MIT-Lizenz. Sie erlaubt ausdrücklich Nutzung, Änderung, Weitergabe, Unterlizenzierung und Verkauf von Kopien unter Einhaltung der Lizenzbedingungen.
|
||||
|
||||
Die Runtime-Lizenz der Universal License Platform ist deshalb primär eine **technische und vertragliche Grenze der offiziellen Distribution**, nicht ein Ersatz für eine bewusst gewählte Software-Lizenzstrategie.
|
||||
|
||||
## Mögliche Produktstrategien
|
||||
|
||||
### Permissive Open Source + kommerzielle Dienste
|
||||
|
||||
MIT/Apache-2.0 bleibt bestehen. Monetarisiert werden beispielsweise:
|
||||
|
||||
- offizielle Managed-/Hosted-Dienste;
|
||||
- Support und SLA;
|
||||
- Verifikations- und Signaturdienste;
|
||||
- Marken-/Trust-Programme;
|
||||
- offizielle Images und Updatekanäle.
|
||||
|
||||
Das maximiert Adoption, schützt aber einzelne lokal vorhandene Funktionen nicht exklusiv.
|
||||
|
||||
### Dual Licensing
|
||||
|
||||
Eine häufige Alternative ist eine Copyleft-Open-Source-Lizenz für die Community-Ausgabe und eine separate kommerzielle Lizenz für Kunden, die die Copyleft-Bedingungen nicht übernehmen möchten. Eine solche Umstellung sollte nur vorgenommen werden, wenn die erforderlichen Rechte an allen Beiträgen vorliegen.
|
||||
|
||||
### Open Core
|
||||
|
||||
Ein vollständig offener Kern und separat lizenzierte, nicht unter der Open-Source-Lizenz veröffentlichte Enterprise-Komponenten sind ebenfalls möglich. Dabei muss die Lizenzgrenze im Repository und in Build-Artefakten eindeutig dokumentiert werden.
|
||||
|
||||
## Empfehlung vor öffentlichem Launch
|
||||
|
||||
Vor einer kommerziellen Veröffentlichung sollten insbesondere entschieden und juristisch geprüft werden:
|
||||
|
||||
1. welche Teile tatsächlich Open Source sein sollen;
|
||||
2. ob MIT beibehalten, Dual Licensing verwendet oder ein Open-Core-Modell gewählt wird;
|
||||
3. welche Marken/Logos ausschließlich für offizielle bzw. verifizierte Implementierungen verwendet werden dürfen;
|
||||
4. welche Vertragsbedingungen für Runtime-Lizenzen, Support, SLA und Verifikation gelten.
|
||||
|
||||
Diese Datei beschreibt Produktarchitektur und ersetzt keine Rechtsberatung.
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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.
|
||||
13
docs/OPERATOR-PAGES.md
Normal file
13
docs/OPERATOR-PAGES.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Operator, privacy and accessibility pages
|
||||
|
||||
Version 2.0.2 provides multilingual operator pages in `full` and `bulk` mode:
|
||||
|
||||
- `/legal` (`/imprint` is an alias)
|
||||
- `/privacy`
|
||||
- `/accessibility`
|
||||
|
||||
All eight application languages are supported through `?lang=de`, `?lang=en`, `?lang=fr`, `?lang=es`, `?lang=it`, `?lang=nl`, `?lang=pt` and `?lang=pl`. Each page emits language-alternate metadata.
|
||||
|
||||
These routes remain reachable when `REQUIRE_LICENSE=true` and the runtime licence is unavailable, so legally relevant operator information does not disappear because of a licensing incident. Their static assets are exempt for the same reason.
|
||||
|
||||
The content is generated from environment configuration. Empty optional fields are not rendered. `LEGAL_STRICT` can be used as a production deployment guard, but it does not assert that every field required by every jurisdiction is present.
|
||||
@@ -1,85 +0,0 @@
|
||||
# Rechts- und Sicherheitsreview
|
||||
|
||||
Stand: 20. Juli 2026
|
||||
|
||||
## Einordnung
|
||||
|
||||
Dieses Review verbessert den technischen Standardzustand und stellt konfigurierbare Vorlagen für zentrale Betreiberinformationen bereit. Es ist keine individuelle Rechtsberatung und keine Garantie vollständiger Rechtskonformität. Die abschließende Prüfung muss anhand des tatsächlichen Betreibers, Sitzes, Geschäftsmodells, Hostings, Vertragsangebots und aller eingebundenen Dienste erfolgen.
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Im geprüften Archiv fehlten eigene Seiten für Anbieterkennzeichnung, Datenschutz und Barrierefreiheit. Zudem wurde die Client-IP bei jedem Request protokolliert, Proxy-Header konnten ohne explizite Netzbindung vertraut werden, der Metrik-Endpunkt war grundsätzlich registriert und der öffentliche Capability-Endpunkt konnte interne Lizenzmetadaten ausgeben.
|
||||
|
||||
## Umgesetzte Änderungen
|
||||
|
||||
### Rechtliche Informationen
|
||||
|
||||
- neue Routen `/impressum`, `/datenschutz` und `/barrierefreiheit`;
|
||||
- zentrale Footer-Verlinkung auf allen HTML-Seiten;
|
||||
- konfigurierbare Betreiber-, Register-, Steuer-, Datenschutz-, Hosting-, Streitbeilegungs- und Barrierefreiheitsangaben;
|
||||
- sichtbare Warnung bei fehlenden Angaben;
|
||||
- `LEGAL_STRICT=true` verhindert einen Produktivstart mit fehlenden Kernangaben oder typischen Platzhaltern;
|
||||
- keine erfundenen Betreiberangaben und keine pauschale Behauptung vollständiger Barrierefreiheit;
|
||||
- Hinweise auf die tatsächliche Verarbeitung von URL-Parametern, Infrastruktur-Logs und optionaler Online-Lizenzprüfung;
|
||||
- kein veralteter Link zur eingestellten EU-OS-Plattform.
|
||||
|
||||
### Anwendungssicherheit und Datenschutz durch Voreinstellungen
|
||||
|
||||
- Client-IP-Logging standardmäßig deaktiviert;
|
||||
- Query-Strings werden nicht in Anwendungslogs übernommen;
|
||||
- Proxy-Header werden nur von explizit konfigurierten CIDR-Netzen ausgewertet;
|
||||
- die weitergeleitete IP wird von rechts nach links über die vertrauenswürdige Proxy-Kette bestimmt, damit vorgeschobene Header-Werte nicht als Client-IP gelten;
|
||||
- validierte und auf 64 Zeichen begrenzte Request-IDs;
|
||||
- restriktive Content Security Policy mit zufälliger Nonce je HTML-Antwort;
|
||||
- HSTS nur bei HTTPS-Konfiguration und ohne pauschales `includeSubDomains`;
|
||||
- `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `nosniff`, Referrer- und Permissions-Policy;
|
||||
- Validator mit 1-MiB-Limit, strikt erlaubten JSON-Feldern, Content-Type-Prüfung und Parallelitätsgrenze;
|
||||
- `/metrics` standardmäßig nicht registriert; bei Aktivierung ist ein Bearer-Token zwingend;
|
||||
- öffentliche Capability-Antwort enthält keine Lizenz-ID, Kundendaten, internen Serveradressen oder Diagnosedetails;
|
||||
- externe Konfigurations-URLs werden auf absolute HTTP(S)-URLs ohne Zugangsdaten begrenzt;
|
||||
- rechtliche Kontaktfelder werden als reine E-Mail-Adressen validiert;
|
||||
- fehlerhafte boolesche Umgebungsvariablen und ungültige Proxy-CIDRs führen zum Startfehler statt zu einem stillen Fallback.
|
||||
|
||||
### Deployment
|
||||
|
||||
- Compose-, Kubernetes- und Swarm-Beispiele um die Rechts- und Sicherheitskonfiguration erweitert;
|
||||
- sichere Containeroptionen beibehalten: Non-Root, read-only Root-Dateisystem, keine Linux-Capabilities, `no-new-privileges`;
|
||||
- öffentliche Prometheus-Autodiscovery aus dem Kubernetes-Beispiel entfernt;
|
||||
- Dokumentation für TLS, Rate Limits, Secrets, Netzsegmentierung, Löschfristen, Backups, Updates und Incident Response ergänzt.
|
||||
|
||||
## Noch durch den Betreiber zu erledigen
|
||||
|
||||
Vor einem öffentlichen Start sind mindestens folgende Punkte mit echten Angaben und der tatsächlichen Infrastruktur abzugleichen:
|
||||
|
||||
1. Firma/Name, ladungsfähige Anschrift, Vertretungsberechtigte, Register, Umsatzsteuer-ID und gegebenenfalls berufsrechtliche Angaben.
|
||||
2. Redaktionell Verantwortliche, falls journalistisch-redaktionelle Inhalte unter § 18 Abs. 2 MStV angeboten werden.
|
||||
3. Hosting-Anbieter, eingesetzte Reverse Proxies/CDNs, tatsächliche Logfelder, Löschfristen, Auftragsverarbeitungsverträge und mögliche Drittlandübermittlungen.
|
||||
4. Anwendbarkeit und zutreffender Status nach dem Verbraucherstreitbeilegungsgesetz.
|
||||
5. Anwendbarkeit von BFSG/BFSGV sowie ein tatsächlicher Accessibility-Test des vollständigen Angebots.
|
||||
6. Datenschutzrechtliche Einordnung des Lizenzservers bei Hybrid- oder Online-Betrieb.
|
||||
7. TLS, Edge-Rate-Limits, Secret-Management, Patch-Prozess, Schwachstellenscans, Monitoring, Backups und Incident-Response in der Zielumgebung.
|
||||
8. Juristische Schlussprüfung der veröffentlichten Texte, insbesondere bei Verbraucherverträgen, reglementierten Berufen oder internationalen Angeboten.
|
||||
|
||||
## Technische Prüfung
|
||||
|
||||
Ausgeführt wurden:
|
||||
|
||||
```text
|
||||
go test ./...
|
||||
go vet ./...
|
||||
cd third_party/license-platform-client && go test ./... && go vet ./...
|
||||
```
|
||||
|
||||
Alle Prüfungen waren erfolgreich. Da die isolierte Prüfumgebung die in `go.mod` verlangte Go-1.26-Toolchain nicht herunterladen konnte, wurden die Tests mit der lokal vorhandenen Go-Version 1.23.2 und einer nur temporär angepassten Modfile ausgeführt. Die Projektdatei `go.mod` bleibt unverändert bei Go 1.26.
|
||||
|
||||
Zusätzlich wurden alle YAML-Dateien geparst, die Go-Dateien mit `gofmt` geprüft und Templates/JavaScript auf Inline-Eventhandler, Cookies sowie Browser-Speicherzugriffe durchsucht.
|
||||
|
||||
## Offizielle Ausgangspunkte
|
||||
|
||||
- § 5 DDG: https://www.gesetze-im-internet.de/ddg/__5.html
|
||||
- § 18 MStV: https://www.gesetze-bayern.de/Content/Document/MStV-18
|
||||
- Art. 13 DSGVO: https://eur-lex.europa.eu/eli/reg/2016/679/oj
|
||||
- § 25 TDDDG: https://www.gesetze-im-internet.de/ttdsg/__25.html
|
||||
- § 36 VSBG: https://www.gesetze-im-internet.de/vsbg/__36.html
|
||||
- Bundesfachstelle Barrierefreiheit zum BFSG: https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz
|
||||
- EU AI Act: https://eur-lex.europa.eu/eli/reg/2024/1689/oj
|
||||
33
docs/SUPPLY-CHAIN.md
Normal file
33
docs/SUPPLY-CHAIN.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Supply-chain security
|
||||
|
||||
Die offizielle GitHub-Actions-Pipeline erzeugt für den normalen und den Bulk-Container Multi-Arch-Images für `linux/amd64` und `linux/arm64`.
|
||||
|
||||
Für veröffentlichte Images werden zusätzlich erzeugt:
|
||||
|
||||
- BuildKit-Provenance (`mode=max`);
|
||||
- ein SBOM-Attest;
|
||||
- eine keyless Sigstore/Cosign-Signatur des Multi-Arch-Image-Digests über GitHub OIDC.
|
||||
|
||||
Die Signatur schützt die Herkunft eines offiziellen Image-Digests. Sie ersetzt weder die Runtime-Lizenzprüfung noch eine fachliche Verifikation einer KI-Nutzungserklärung.
|
||||
|
||||
## Verifikation eines offiziellen Images
|
||||
|
||||
Das konkrete Repository und der GitHub-Workflow müssen bei der Verifikation als erwartete Identität verwendet werden. Beispiel:
|
||||
|
||||
```bash
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp='https://github.com/OWNER/REPOSITORY/.github/workflows/ci.yml@refs/heads/main' \
|
||||
ghcr.io/OWNER/REPOSITORY@sha256:...
|
||||
```
|
||||
|
||||
Für besonders kontrollierte Produktionsumgebungen sollte ein Deployment nur freigegebene Digests verwenden und die Signatur vor dem Rollout durch eine Admission Policy oder die CI/CD-Plattform prüfen.
|
||||
|
||||
## Release-Prinzipien
|
||||
|
||||
- Deployment nach Digest statt ausschließlich nach `latest`;
|
||||
- geschützte Release-Tags und Branch-Protection für `main`;
|
||||
- getrennte Secrets für Runtime-Lizenz und Bulk-API-Key;
|
||||
- keine privaten Lizenzschlüssel in diesem Repository;
|
||||
- regelmäßige Aktualisierung der Build-Images und GitHub Actions;
|
||||
- reproduzierbare Versionierung von Schema, Anwendung und Container.
|
||||
185
internal/app/bulk.go
Normal file
185
internal/app/bulk.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/declaration"
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
|
||||
)
|
||||
|
||||
type bulkRequest struct {
|
||||
Items []bulkItem `json:"items"`
|
||||
}
|
||||
|
||||
type bulkItem struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Parameters map[string]string `json:"parameters"`
|
||||
}
|
||||
|
||||
type bulkResult struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
Declaration *declaration.Declaration `json:"declaration,omitempty"`
|
||||
Assessment *declaration.Article50Assessment `json:"article50Assessment,omitempty"`
|
||||
DeclarationURL string `json:"declarationUrl,omitempty"`
|
||||
ManifestURL string `json:"manifestUrl,omitempty"`
|
||||
BadgeURL string `json:"badgeUrl,omitempty"`
|
||||
Error *bulkError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type bulkError struct {
|
||||
Code string `json:"code"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
type declarationBundle struct {
|
||||
Product string `json:"product"`
|
||||
ProductVersion string `json:"productVersion"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Declaration declaration.Declaration `json:"declaration"`
|
||||
DeclarationDigest string `json:"declarationDigest"`
|
||||
Assessment declaration.Article50Assessment `json:"article50Assessment"`
|
||||
DeclarationURL string `json:"declarationUrl"`
|
||||
ManifestURL string `json:"manifestUrl"`
|
||||
BadgeURL string `json:"badgeUrl"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBulkDeclarations(w http.ResponseWriter, r *http.Request) {
|
||||
s.metrics.bulkRequests.Add(1)
|
||||
if !s.bulkAuthorized(r) {
|
||||
s.metrics.bulkFailures.Add(1)
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="bulk-api"`)
|
||||
s.problem(w, http.StatusUnauthorized, "bulk_authentication_required", "A valid bulk API key is required.")
|
||||
return
|
||||
}
|
||||
if !s.licenses.Has(FeatureBulkAPI) {
|
||||
s.metrics.bulkFailures.Add(1)
|
||||
s.problem(w, http.StatusForbidden, "licensed_feature_required", "The bulk API requires the licensed capability bulk_api.")
|
||||
return
|
||||
}
|
||||
limit := s.cfg.BulkMaxItems
|
||||
if licensedLimit, ok := s.licenses.Limit("bulk_items"); ok && licensedLimit > 0 && int64(limit) > licensedLimit {
|
||||
limit = int(licensedLimit)
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, s.cfg.BulkMaxBodyBytes)
|
||||
defer body.Close()
|
||||
dec := json.NewDecoder(body)
|
||||
dec.DisallowUnknownFields()
|
||||
var request bulkRequest
|
||||
if err := dec.Decode(&request); err != nil {
|
||||
s.metrics.bulkFailures.Add(1)
|
||||
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if err := ensureEOF(dec); err != nil {
|
||||
s.metrics.bulkFailures.Add(1)
|
||||
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if len(request.Items) == 0 {
|
||||
s.metrics.bulkFailures.Add(1)
|
||||
s.problem(w, http.StatusBadRequest, "empty_bulk_request", "At least one item is required.")
|
||||
return
|
||||
}
|
||||
if len(request.Items) > limit {
|
||||
s.metrics.bulkFailures.Add(1)
|
||||
s.problem(w, http.StatusRequestEntityTooLarge, "bulk_limit_exceeded", "The request exceeds the configured or licensed bulk item limit.")
|
||||
return
|
||||
}
|
||||
s.metrics.bulkItems.Add(uint64(len(request.Items)))
|
||||
results := make([]bulkResult, 0, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
q := url.Values{}
|
||||
for key, value := range item.Parameters {
|
||||
q.Set(key, value)
|
||||
}
|
||||
if q.Get("lang") == "" {
|
||||
q.Set("lang", s.cfg.DefaultLanguage)
|
||||
}
|
||||
d, err := s.declarationFromQuery(q)
|
||||
if err != nil {
|
||||
results = append(results, bulkResult{ID: strings.TrimSpace(item.ID), Valid: false, Error: &bulkError{Code: "invalid_declaration", Detail: err.Error()}})
|
||||
continue
|
||||
}
|
||||
assessment := declaration.AssessArticle50(d)
|
||||
declarationURL, manifestURL, badgeURL := s.generatedURLs(q)
|
||||
results = append(results, bulkResult{
|
||||
ID: strings.TrimSpace(item.ID), Valid: true, Declaration: &d, Assessment: &assessment,
|
||||
DeclarationURL: declarationURL, ManifestURL: manifestURL, BadgeURL: badgeURL,
|
||||
})
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{
|
||||
"product": ProductID, "productVersion": ProductVersion, "count": len(results), "items": results,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleArticle50Assessment(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)
|
||||
assessment := declaration.AssessArticle50(d)
|
||||
view := article50AssessmentView(assessment, locale)
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{
|
||||
"assessment": assessment,
|
||||
"title": view.Title,
|
||||
"summary": view.Summary,
|
||||
"findings": view.Details,
|
||||
"warnings": view.Warnings,
|
||||
"disclaimer": locale.Text["assessment_disclaimer"],
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeclarationBundle(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.licenses.Has(FeatureExportBundle) {
|
||||
s.problem(w, http.StatusForbidden, "licensed_feature_required", "Declaration bundles require the licensed capability export_bundle.")
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
declarationURL, manifestURL, badgeURL := s.generatedURLs(q)
|
||||
bundle := declarationBundle{
|
||||
Product: ProductID, ProductVersion: ProductVersion, GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Declaration: d, DeclarationDigest: declarationDigest(d), Assessment: declaration.AssessArticle50(d), DeclarationURL: declarationURL, ManifestURL: manifestURL, BadgeURL: badgeURL,
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
s.writeJSON(w, http.StatusOK, bundle)
|
||||
}
|
||||
|
||||
func (s *Server) generatedURLs(q url.Values) (string, string, string) {
|
||||
encoded := q.Encode()
|
||||
base := s.cfg.OutputBaseURL
|
||||
if base == "" {
|
||||
base = s.cfg.BaseURL
|
||||
}
|
||||
declarationURL := base + "/declaration?" + encoded
|
||||
manifestURL := base + "/v1/declaration.json?" + encoded
|
||||
badgeQ := cloneValues(q)
|
||||
badgeQ.Set("link", declarationURL)
|
||||
badgeURL := base + "/v1/badge.svg?" + badgeQ.Encode()
|
||||
return declarationURL, manifestURL, badgeURL
|
||||
}
|
||||
|
||||
func declarationDigest(d declaration.Declaration) string {
|
||||
encoded, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
digest := sha256.Sum256(encoded)
|
||||
return "sha256:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,19 +12,27 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
EnvironmentErrors []string
|
||||
ListenAddress string
|
||||
BaseURL string
|
||||
OutputBaseURL string
|
||||
PublicName string
|
||||
ContactURL string
|
||||
SalesURL string
|
||||
DefaultLanguage string
|
||||
TrustProxy bool
|
||||
TrustedProxies []netip.Prefix
|
||||
LogClientIP bool
|
||||
EnableHSTS bool
|
||||
MetricsEnabled bool
|
||||
MetricsToken string
|
||||
ServiceMode string
|
||||
BulkMaxItems int
|
||||
BulkMaxBodyBytes int64
|
||||
BulkRequireAPIKey bool
|
||||
BulkAPIKey string
|
||||
APIAllowedOrigin string
|
||||
RequireLicense bool
|
||||
WhiteLabel bool
|
||||
|
||||
MetricsEnabled bool
|
||||
MetricsToken string
|
||||
|
||||
LegalStrict bool
|
||||
LegalName string
|
||||
@@ -61,66 +66,62 @@ type Config struct {
|
||||
LicenseCacheFile string
|
||||
LicenseRefreshEvery time.Duration
|
||||
LicenseTimeout time.Duration
|
||||
|
||||
CommunityPrice string
|
||||
ProPrice string
|
||||
PublisherPrice string
|
||||
AgencyPrice string
|
||||
}
|
||||
|
||||
func ConfigFromEnv() Config {
|
||||
var environmentErrors []string
|
||||
contactURL := env("CONTACT_URL", "https://ai.trustednet.eu")
|
||||
contactURL := env("CONTACT_URL", "https://b1tsblog.org/page/ai")
|
||||
baseURL := strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/")
|
||||
mode, err := licensekit.ParseMode(env("LICENSE_MODE", "offline"))
|
||||
if err != nil {
|
||||
environmentErrors = append(environmentErrors, "LICENSE_MODE is invalid")
|
||||
mode = licensekit.ModeOffline
|
||||
}
|
||||
trustProxy := boolEnv("TRUST_PROXY", false, &environmentErrors)
|
||||
logClientIP := boolEnv("LOG_CLIENT_IP", false, &environmentErrors)
|
||||
enableHSTS := boolEnv("ENABLE_HSTS", true, &environmentErrors)
|
||||
metricsEnabled := boolEnv("METRICS_ENABLED", false, &environmentErrors)
|
||||
legalStrict := boolEnv("LEGAL_STRICT", false, &environmentErrors)
|
||||
trustedProxies := prefixListEnv("TRUSTED_PROXY_CIDRS", &environmentErrors)
|
||||
return Config{
|
||||
EnvironmentErrors: environmentErrors,
|
||||
ListenAddress: env("LISTEN_ADDRESS", ":8080"),
|
||||
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
|
||||
PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"),
|
||||
BaseURL: baseURL,
|
||||
OutputBaseURL: strings.TrimRight(env("OUTPUT_BASE_URL", baseURL), "/"),
|
||||
PublicName: env("PUBLIC_NAME", "UCNG"),
|
||||
ContactURL: contactURL,
|
||||
SalesURL: env("SALES_URL", contactURL),
|
||||
DefaultLanguage: env("DEFAULT_LANGUAGE", "de"),
|
||||
TrustProxy: trustProxy,
|
||||
TrustedProxies: trustedProxies,
|
||||
LogClientIP: logClientIP,
|
||||
EnableHSTS: enableHSTS,
|
||||
MetricsEnabled: metricsEnabled,
|
||||
MetricsToken: secretEnv("METRICS_TOKEN"),
|
||||
TrustProxy: boolEnv("TRUST_PROXY", false),
|
||||
TrustedProxies: cidrEnv("TRUSTED_PROXY_CIDRS"),
|
||||
LogClientIP: boolEnv("LOG_CLIENT_IP", false),
|
||||
EnableHSTS: boolEnv("ENABLE_HSTS", false),
|
||||
ServiceMode: serviceModeEnv("SERVICE_MODE", "full"),
|
||||
BulkMaxItems: intEnv("BULK_MAX_ITEMS", 500),
|
||||
BulkMaxBodyBytes: int64(intEnv("BULK_MAX_BODY_BYTES", 4<<20)),
|
||||
BulkRequireAPIKey: boolEnv("BULK_REQUIRE_API_KEY", false),
|
||||
BulkAPIKey: secretEnv("BULK_API_KEY"),
|
||||
APIAllowedOrigin: envAllowEmpty("API_ALLOWED_ORIGIN", "*"),
|
||||
RequireLicense: boolEnv("REQUIRE_LICENSE", false),
|
||||
WhiteLabel: boolEnv("WHITE_LABEL", false),
|
||||
|
||||
LegalStrict: legalStrict,
|
||||
LegalName: env("LEGAL_NAME", ""),
|
||||
LegalAddress: env("LEGAL_ADDRESS", ""),
|
||||
LegalRepresentative: env("LEGAL_REPRESENTATIVE", ""),
|
||||
LegalEmail: env("LEGAL_EMAIL", ""),
|
||||
LegalPhone: env("LEGAL_PHONE", ""),
|
||||
LegalRegister: env("LEGAL_REGISTER", ""),
|
||||
LegalRegisterNumber: env("LEGAL_REGISTER_NUMBER", ""),
|
||||
LegalVATID: env("LEGAL_VAT_ID", ""),
|
||||
EditorialResponsibleName: env("EDITORIAL_RESPONSIBLE_NAME", ""),
|
||||
EditorialResponsibleAddress: env("EDITORIAL_RESPONSIBLE_ADDRESS", ""),
|
||||
DataProtectionContact: env("DATA_PROTECTION_CONTACT", ""),
|
||||
HostingProvider: env("HOSTING_PROVIDER", ""),
|
||||
HostingAddress: env("HOSTING_ADDRESS", ""),
|
||||
LogRetention: env("LOG_RETENTION", ""),
|
||||
DataRecipients: env("DATA_RECIPIENTS", ""),
|
||||
ThirdCountryTransfers: env("THIRD_COUNTRY_TRANSFERS", ""),
|
||||
SupervisoryAuthorityName: env("SUPERVISORY_AUTHORITY_NAME", ""),
|
||||
SupervisoryAuthorityURL: env("SUPERVISORY_AUTHORITY_URL", ""),
|
||||
ConsumerDisputeStatus: strings.ToLower(env("CONSUMER_DISPUTE_STATUS", "")),
|
||||
ConsumerDisputeBody: env("CONSUMER_DISPUTE_BODY", ""),
|
||||
ConsumerDisputeURL: env("CONSUMER_DISPUTE_URL", ""),
|
||||
AccessibilityContact: env("ACCESSIBILITY_CONTACT", ""),
|
||||
AccessibilityStatus: env("ACCESSIBILITY_STATUS", ""),
|
||||
MetricsEnabled: boolEnv("METRICS_ENABLED", false),
|
||||
MetricsToken: secretEnv("METRICS_TOKEN"),
|
||||
|
||||
LegalStrict: boolEnv("LEGAL_STRICT", false),
|
||||
LegalName: envAllowEmpty("LEGAL_NAME", ""),
|
||||
LegalAddress: envAllowEmpty("LEGAL_ADDRESS", ""),
|
||||
LegalRepresentative: envAllowEmpty("LEGAL_REPRESENTATIVE", ""),
|
||||
LegalEmail: envAllowEmpty("LEGAL_EMAIL", ""),
|
||||
LegalPhone: envAllowEmpty("LEGAL_PHONE", ""),
|
||||
LegalRegister: envAllowEmpty("LEGAL_REGISTER", ""),
|
||||
LegalRegisterNumber: envAllowEmpty("LEGAL_REGISTER_NUMBER", ""),
|
||||
LegalVATID: envAllowEmpty("LEGAL_VAT_ID", ""),
|
||||
EditorialResponsibleName: envAllowEmpty("EDITORIAL_RESPONSIBLE_NAME", ""),
|
||||
EditorialResponsibleAddress: envAllowEmpty("EDITORIAL_RESPONSIBLE_ADDRESS", ""),
|
||||
DataProtectionContact: envAllowEmpty("DATA_PROTECTION_CONTACT", ""),
|
||||
HostingProvider: envAllowEmpty("HOSTING_PROVIDER", ""),
|
||||
HostingAddress: envAllowEmpty("HOSTING_ADDRESS", ""),
|
||||
LogRetention: envAllowEmpty("LOG_RETENTION", ""),
|
||||
DataRecipients: envAllowEmpty("DATA_RECIPIENTS", ""),
|
||||
ThirdCountryTransfers: envAllowEmpty("THIRD_COUNTRY_TRANSFERS", ""),
|
||||
SupervisoryAuthorityName: envAllowEmpty("SUPERVISORY_AUTHORITY_NAME", ""),
|
||||
SupervisoryAuthorityURL: envAllowEmpty("SUPERVISORY_AUTHORITY_URL", ""),
|
||||
ConsumerDisputeStatus: disputeStatusEnv("CONSUMER_DISPUTE_STATUS", "not_applicable"),
|
||||
ConsumerDisputeBody: envAllowEmpty("CONSUMER_DISPUTE_BODY", ""),
|
||||
ConsumerDisputeURL: envAllowEmpty("CONSUMER_DISPUTE_URL", ""),
|
||||
AccessibilityContact: envAllowEmpty("ACCESSIBILITY_CONTACT", ""),
|
||||
AccessibilityStatus: envAllowEmpty("ACCESSIBILITY_STATUS", ""),
|
||||
|
||||
LicenseToken: secretEnv("LICENSE_TOKEN"),
|
||||
LicenseMode: mode,
|
||||
@@ -129,120 +130,38 @@ func ConfigFromEnv() Config {
|
||||
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 validateConfig(cfg Config) error {
|
||||
if len(cfg.EnvironmentErrors) > 0 {
|
||||
return fmt.Errorf("invalid environment configuration: %s", strings.Join(cfg.EnvironmentErrors, "; "))
|
||||
func (c Config) Validate() error {
|
||||
if c.TrustProxy && len(c.TrustedProxies) == 0 {
|
||||
return fmt.Errorf("TRUST_PROXY=true requires at least one valid TRUSTED_PROXY_CIDRS entry")
|
||||
}
|
||||
u, err := url.Parse(cfg.BaseURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("BASE_URL must be an absolute http(s) origin without path, query, credentials or fragment")
|
||||
if c.MetricsEnabled && len(c.MetricsToken) < 32 {
|
||||
return fmt.Errorf("METRICS_ENABLED=true requires METRICS_TOKEN or METRICS_TOKEN_FILE with at least 32 characters")
|
||||
}
|
||||
if strings.TrimSpace(cfg.PublicName) == "" {
|
||||
return errors.New("PUBLIC_NAME must not be empty")
|
||||
}
|
||||
if cfg.TrustProxy && len(cfg.TrustedProxies) == 0 {
|
||||
return errors.New("TRUST_PROXY=true requires at least one TRUSTED_PROXY_CIDRS entry")
|
||||
}
|
||||
if cfg.MetricsEnabled && configValueMissing(cfg.MetricsToken) {
|
||||
return errors.New("METRICS_ENABLED=true requires METRICS_TOKEN or METRICS_TOKEN_FILE")
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name, value string
|
||||
}{
|
||||
{"CONTACT_URL", cfg.ContactURL},
|
||||
{"SALES_URL", cfg.SalesURL},
|
||||
{"SUPERVISORY_AUTHORITY_URL", cfg.SupervisoryAuthorityURL},
|
||||
{"CONSUMER_DISPUTE_URL", cfg.ConsumerDisputeURL},
|
||||
} {
|
||||
if err := validateOptionalHTTPURL(item.name, item.value); err != nil {
|
||||
return err
|
||||
if c.LegalStrict {
|
||||
required := map[string]string{
|
||||
"LEGAL_NAME": c.LegalName,
|
||||
"LEGAL_ADDRESS": c.LegalAddress,
|
||||
"LEGAL_EMAIL": c.LegalEmail,
|
||||
"DATA_PROTECTION_CONTACT": c.DataProtectionContact,
|
||||
"HOSTING_PROVIDER": c.HostingProvider,
|
||||
"HOSTING_ADDRESS": c.HostingAddress,
|
||||
}
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name, value string
|
||||
}{
|
||||
{"LEGAL_EMAIL", cfg.LegalEmail},
|
||||
{"DATA_PROTECTION_CONTACT", cfg.DataProtectionContact},
|
||||
{"ACCESSIBILITY_CONTACT", cfg.AccessibilityContact},
|
||||
} {
|
||||
if err := validateOptionalEmail(item.name, item.value); err != nil {
|
||||
return err
|
||||
missing := make([]string, 0)
|
||||
for key, value := range required {
|
||||
if placeholderOrEmpty(value) {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.LegalStrict {
|
||||
missing := legalRequiredFields(cfg)
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("LEGAL_STRICT=true but required legal configuration is missing: %s", strings.Join(missing, ", "))
|
||||
return fmt.Errorf("LEGAL_STRICT=true but required operator fields are missing or placeholders: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
switch cfg.ConsumerDisputeStatus {
|
||||
case "", "not_applicable", "not_participating", "participating":
|
||||
default:
|
||||
return errors.New("CONSUMER_DISPUTE_STATUS must be empty, not_applicable, not_participating or participating")
|
||||
}
|
||||
if cfg.ConsumerDisputeStatus == "participating" && (cfg.ConsumerDisputeBody == "" || cfg.ConsumerDisputeURL == "") {
|
||||
return errors.New("CONSUMER_DISPUTE_BODY and CONSUMER_DISPUTE_URL are required when CONSUMER_DISPUTE_STATUS=participating")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func legalRequiredFields(cfg Config) []string {
|
||||
var missing []string
|
||||
for _, field := range []struct{ key, value string }{
|
||||
{"LEGAL_NAME", cfg.LegalName},
|
||||
{"LEGAL_ADDRESS", cfg.LegalAddress},
|
||||
{"LEGAL_EMAIL", cfg.LegalEmail},
|
||||
} {
|
||||
if configValueMissing(field.value) {
|
||||
missing = append(missing, field.key)
|
||||
if c.ConsumerDisputeStatus == "participating" && (placeholderOrEmpty(c.ConsumerDisputeBody) || placeholderOrEmpty(c.ConsumerDisputeURL)) {
|
||||
return fmt.Errorf("CONSUMER_DISPUTE_STATUS=participating requires CONSUMER_DISPUTE_BODY and CONSUMER_DISPUTE_URL")
|
||||
}
|
||||
}
|
||||
if configValueMissing(cfg.LogRetention) {
|
||||
missing = append(missing, "LOG_RETENTION")
|
||||
}
|
||||
if configValueMissing(cfg.HostingProvider) {
|
||||
missing = append(missing, "HOSTING_PROVIDER")
|
||||
}
|
||||
if configValueMissing(cfg.ConsumerDisputeStatus) {
|
||||
missing = append(missing, "CONSUMER_DISPUTE_STATUS")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func configValueMissing(value string) bool {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
return value == "" || value == "REPLACE_ME" || value == "CHANGEME" || value == "TODO"
|
||||
}
|
||||
|
||||
func validateOptionalHTTPURL(name, value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
|
||||
return fmt.Errorf("%s must be an absolute http(s) URL without credentials", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptionalEmail(name, value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || configValueMissing(value) {
|
||||
return nil
|
||||
}
|
||||
address, err := mail.ParseAddress(value)
|
||||
if err != nil || address.Address != value {
|
||||
return fmt.Errorf("%s must be a plain valid email address", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -253,51 +172,40 @@ func env(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolEnv(key string, fallback bool, environmentErrors *[]string) bool {
|
||||
func envAllowEmpty(key, fallback string) string {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func secretEnv(key string) string {
|
||||
// Explicit *_FILE configuration is authoritative. This avoids a stale
|
||||
// environment value silently overriding Docker/Kubernetes secrets.
|
||||
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
|
||||
if path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv(key))
|
||||
}
|
||||
|
||||
func boolEnv(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
*environmentErrors = append(*environmentErrors, key+" must be true or false")
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func secretEnv(key string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
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 prefixListEnv(key string, environmentErrors *[]string) []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, raw := range strings.Split(os.Getenv(key), ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(raw)
|
||||
if err != nil {
|
||||
*environmentErrors = append(*environmentErrors, key+" contains invalid CIDR "+raw)
|
||||
continue
|
||||
}
|
||||
out = append(out, prefix.Masked())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func durationEnv(key string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
@@ -311,3 +219,71 @@ func durationEnv(key string, fallback time.Duration) time.Duration {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func intEnv(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func serviceModeEnv(key, fallback string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
switch value {
|
||||
case "full", "api", "bulk":
|
||||
return value
|
||||
case "":
|
||||
return fallback
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func disputeStatusEnv(key, fallback string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
switch value {
|
||||
case "not_applicable", "not_participating", "participating":
|
||||
return value
|
||||
case "":
|
||||
return fallback
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func cidrEnv(key string) []netip.Prefix {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]netip.Prefix, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if prefix, err := netip.ParsePrefix(part); err == nil {
|
||||
out = append(out, prefix.Masked())
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(part); err == nil {
|
||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func placeholderOrEmpty(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
upper := strings.ToUpper(trimmed)
|
||||
return strings.Contains(upper, "REPLACE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "TODO")
|
||||
}
|
||||
|
||||
157
internal/app/config_test.go
Normal file
157
internal/app/config_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigValidationRequiresTrustedProxyCIDRs(t *testing.T) {
|
||||
cfg := Config{TrustProxy: true}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXY_CIDRS") {
|
||||
t.Fatalf("expected trusted proxy validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidationRequiresMetricsToken(t *testing.T) {
|
||||
cfg := Config{MetricsEnabled: true, MetricsToken: "short"}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "METRICS_TOKEN") {
|
||||
t.Fatalf("expected metrics token validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidationLegalStrict(t *testing.T) {
|
||||
cfg := Config{LegalStrict: true}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "LEGAL_NAME") {
|
||||
t.Fatalf("expected legal validation error, got %v", err)
|
||||
}
|
||||
cfg.LegalName = "Example GmbH"
|
||||
cfg.LegalAddress = "Example Street 1\n12345 Example"
|
||||
cfg.LegalEmail = "legal@example.org"
|
||||
cfg.DataProtectionContact = "privacy@example.org"
|
||||
cfg.HostingProvider = "Example Hosting GmbH"
|
||||
cfg.HostingAddress = "Hosting Street 1\n12345 Example"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("valid strict legal configuration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsDisabledAndProtected(t *testing.T) {
|
||||
h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de"})
|
||||
r := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("disabled metrics status %d", w.Code)
|
||||
}
|
||||
|
||||
cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true, MetricsToken: strings.Repeat("x", 40)}
|
||||
h = testHandlerConfig(t, cfg)
|
||||
r = httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("metrics without token status %d", w.Code)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+cfg.MetricsToken)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "ai_disclosure_uptime_seconds") {
|
||||
t.Fatalf("authorized metrics failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorPagesAndHSTS(t *testing.T) {
|
||||
cfg := Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Example", DefaultLanguage: "de", EnableHSTS: true,
|
||||
LegalName: "Example GmbH", LegalAddress: "Example Street 1", LegalEmail: "legal@example.org",
|
||||
DataProtectionContact: "privacy@example.org", HostingProvider: "Host GmbH", HostingAddress: "Host Street 1",
|
||||
AccessibilityContact: "access@example.org", AccessibilityStatus: "Teilweise konform getestet",
|
||||
}
|
||||
h := testHandlerConfig(t, cfg)
|
||||
for _, tc := range []struct{ path, want string }{{"/legal?lang=de", "Example GmbH"}, {"/privacy?lang=en", "Privacy information"}, {"/accessibility?lang=fr", "Accessibilité"}} {
|
||||
r := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), tc.want) {
|
||||
t.Fatalf("%s: %d, missing %q", tc.path, w.Code, tc.want)
|
||||
}
|
||||
if got := w.Header().Get("Strict-Transport-Security"); got == "" {
|
||||
t.Fatalf("%s missing HSTS", tc.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvExampleDocumentsRuntimeConfiguration(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
data, err := os.ReadFile(filepath.Join(root, ".env.example"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
keys := map[string]struct{}{}
|
||||
files := []string{filepath.Join("config.go"), filepath.Join(root, "cmd", "server", "main.go")}
|
||||
helpers := map[string]bool{
|
||||
"env": true, "envAllowEmpty": true, "boolEnv": true, "durationEnv": true, "intEnv": true,
|
||||
"serviceModeEnv": true, "disputeStatusEnv": true, "cidrEnv": true, "secretEnv": true,
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
for _, filename := range files {
|
||||
file, err := parser.ParseFile(fset, filename, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", filename, err)
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
name := ""
|
||||
switch fn := call.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
if helpers[fn.Name] {
|
||||
name = fn.Name
|
||||
}
|
||||
case *ast.SelectorExpr:
|
||||
if ident, ok := fn.X.(*ast.Ident); ok && ident.Name == "os" && (fn.Sel.Name == "Getenv" || fn.Sel.Name == "LookupEnv") {
|
||||
name = fn.Sel.Name
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
lit, ok := call.Args[0].(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
key, err := strconv.Unquote(lit.Value)
|
||||
if err != nil || key == "" {
|
||||
return true
|
||||
}
|
||||
keys[key] = struct{}{}
|
||||
if name == "secretEnv" {
|
||||
keys[key+"_FILE"] = struct{}{}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
for key := range keys {
|
||||
if !strings.Contains(text, key+"=") {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf(".env.example is missing runtime variables: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
|
||||
)
|
||||
|
||||
type legalNav struct {
|
||||
Imprint string
|
||||
Privacy string
|
||||
Accessibility string
|
||||
}
|
||||
|
||||
type legalField struct {
|
||||
type legalItem struct {
|
||||
Label string
|
||||
Value string
|
||||
URL string
|
||||
@@ -21,320 +16,297 @@ type legalField struct {
|
||||
type legalSection struct {
|
||||
Title string
|
||||
Paragraphs []string
|
||||
Fields []legalField
|
||||
Bullets []string
|
||||
Items []legalItem
|
||||
}
|
||||
|
||||
type legalPage struct {
|
||||
Title string
|
||||
MetaDescription string
|
||||
Eyebrow string
|
||||
Intro string
|
||||
Warning string
|
||||
Sections []legalSection
|
||||
LastUpdated string
|
||||
Kind string
|
||||
Title string
|
||||
Lead string
|
||||
Sections []legalSection
|
||||
NoticeTitle string
|
||||
Notice string
|
||||
}
|
||||
|
||||
func legalNavFor(lang string) legalNav {
|
||||
if lang == "de" {
|
||||
return legalNav{Imprint: "Impressum", Privacy: "Datenschutz", Accessibility: "Barrierefreiheit"}
|
||||
}
|
||||
return legalNav{Imprint: "Legal notice", Privacy: "Privacy", Accessibility: "Accessibility"}
|
||||
type legalCopy struct {
|
||||
LegalLabel, PrivacyLabel, AccessibilityLabel string
|
||||
LegalTitle, LegalLead string
|
||||
Operator, Representative, Contact string
|
||||
Address, Email, Phone, Register, RegisterNo string
|
||||
VAT, Editorial, EditorialAddress string
|
||||
DisputeTitle, DisputeNA, DisputeNo, DisputeYes string
|
||||
PrivacyTitle, PrivacyLead string
|
||||
Controller, DataProtection, Hosting string
|
||||
HostingAddress, Logs, Retention, Recipients string
|
||||
Transfers, Authority string
|
||||
PrivacyTechTitle, PrivacyTechText string
|
||||
AccessibilityTitle, AccessibilityLead string
|
||||
AccessibilityContact, AccessibilityStatus string
|
||||
AccessibilityTechTitle, AccessibilityTechText string
|
||||
NoticeTitle, Notice string
|
||||
}
|
||||
|
||||
func imprintPage(cfg Config, lang string) legalPage {
|
||||
missing := missingNamed(cfg, map[string]string{
|
||||
"LEGAL_NAME": cfg.LegalName, "LEGAL_ADDRESS": cfg.LegalAddress, "LEGAL_EMAIL": cfg.LegalEmail,
|
||||
"CONSUMER_DISPUTE_STATUS": cfg.ConsumerDisputeStatus,
|
||||
})
|
||||
if lang == "de" {
|
||||
sections := []legalSection{
|
||||
{
|
||||
Title: "Anbieter und Kontakt",
|
||||
Fields: compactFields([]legalField{
|
||||
{Label: "Name / Firma", Value: requiredValue(cfg.LegalName)},
|
||||
{Label: "Ladungsfähige Anschrift", Value: requiredValue(cfg.LegalAddress)},
|
||||
{Label: "Vertretungsberechtigt", Value: cfg.LegalRepresentative},
|
||||
{Label: "E-Mail", Value: requiredValue(cfg.LegalEmail), URL: mailto(cfg.LegalEmail)},
|
||||
{Label: "Telefon", Value: cfg.LegalPhone, URL: tel(cfg.LegalPhone)},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Title: "Register und Steuerangaben",
|
||||
Fields: compactFields([]legalField{
|
||||
{Label: "Registergericht / Register", Value: cfg.LegalRegister},
|
||||
{Label: "Registernummer", Value: cfg.LegalRegisterNumber},
|
||||
{Label: "Umsatzsteuer-ID", Value: cfg.LegalVATID},
|
||||
}),
|
||||
Paragraphs: []string{"Es werden nur Daten angezeigt, wenn der Betreiber tatsächlich relevante Daten anzugeben hat. Die Software ist grundsätzlich nicht für gewinnorientiere Zwecke freigegeben."},
|
||||
},
|
||||
}
|
||||
if cfg.EditorialResponsibleName != "" || cfg.EditorialResponsibleAddress != "" {
|
||||
sections = append(sections, legalSection{
|
||||
Title: "Inhaltlich verantwortlich",
|
||||
Paragraphs: []string{"Verantwortlich für journalistisch-redaktionelle Inhalte, soweit § 18 Abs. 2 Medienstaatsvertrag anwendbar ist:"},
|
||||
Fields: compactFields([]legalField{{Label: "Name", Value: cfg.EditorialResponsibleName}, {Label: "Anschrift", Value: cfg.EditorialResponsibleAddress}}),
|
||||
})
|
||||
}
|
||||
if dispute := disputeSection(cfg, true); dispute.Title != "" {
|
||||
sections = append(sections, dispute)
|
||||
}
|
||||
sections = append(sections,
|
||||
legalSection{
|
||||
Title: "Hinweise zu Inhalten und Kennzeichnungen",
|
||||
Paragraphs: []string{
|
||||
"Die Anwendung erzeugt technische Transparenzhinweise auf Grundlage der eingegebenen Angaben. Sie prüft nicht automatisch, ob eine konkrete Kennzeichnung gesetzlich erforderlich, vollständig oder inhaltlich richtig ist.",
|
||||
"Für veröffentlichte Erklärungen, redaktionelle Inhalte und eingebundene Links bleibt die jeweils veröffentlichende Person oder Organisation verantwortlich.",
|
||||
},
|
||||
},
|
||||
legalSection{
|
||||
Title: "Rechtsgrundlagen der Anbieterkennzeichnung",
|
||||
Paragraphs: []string{"Je nach Angebot können insbesondere § 5 Digitale-Dienste-Gesetz und § 18 Medienstaatsvertrag einschlägig sein. Die konkrete Pflicht hängt vom Betreiber, Geschäftsmodell und Inhalt ab."},
|
||||
Fields: []legalField{
|
||||
{Label: "§ 5 DDG", Value: "Allgemeine Informationspflichten", URL: "https://www.gesetze-im-internet.de/ddg/__5.html"},
|
||||
{Label: "§ 18 MStV", Value: "Informationspflichten und Auskunftsrechte", URL: "https://www.gesetze-bayern.de/Content/Document/MStV-18"},
|
||||
},
|
||||
},
|
||||
)
|
||||
return legalPage{
|
||||
Title: "Impressum", MetaDescription: "Anbieterkennzeichnung und Kontaktangaben.", Eyebrow: "Rechtliche Informationen",
|
||||
Intro: "Betreiberangaben für diesen digitalen Dienst.",
|
||||
Warning: legalWarning(missing, true), Sections: sections, LastUpdated: "20. Juli 2026",
|
||||
}
|
||||
func legalLabels(lang string) legalCopy {
|
||||
base := legalCopy{
|
||||
LegalLabel: "Legal notice", PrivacyLabel: "Privacy", AccessibilityLabel: "Accessibility",
|
||||
LegalTitle: "Legal notice", LegalLead: "Operator and contact information for this service.",
|
||||
Operator: "Operator", Representative: "Represented by", Contact: "Contact", Address: "Address", Email: "Email", Phone: "Phone", Register: "Register", RegisterNo: "Register number", VAT: "VAT ID", Editorial: "Editorial responsibility", EditorialAddress: "Address of editorially responsible person",
|
||||
DisputeTitle: "Consumer dispute resolution", DisputeNA: "The operator has assessed the general information obligation as not applicable to this service.", DisputeNo: "The operator states that it does not participate in consumer dispute resolution proceedings unless legally required otherwise.", DisputeYes: "The operator states that it participates in consumer dispute resolution proceedings before the body listed below.",
|
||||
PrivacyTitle: "Privacy information", PrivacyLead: "Information about the operation of this service and the processing of technical connection data.",
|
||||
Controller: "Controller / operator", DataProtection: "Data protection contact", Hosting: "Hosting provider", HostingAddress: "Hosting address", Logs: "Server logs", Retention: "Log retention", Recipients: "Recipients / processors", Transfers: "Third-country transfers", Authority: "Supervisory authority",
|
||||
PrivacyTechTitle: "Technical design", PrivacyTechText: "The application itself does not require advertising trackers or third-party analytics. Depending on the deployment, the reverse proxy, hosting platform or surrounding infrastructure may process connection and log data. The operator must keep the information on this page aligned with the actual deployment.",
|
||||
AccessibilityTitle: "Accessibility", AccessibilityLead: "Information and contact options relating to the accessibility of this service.", AccessibilityContact: "Accessibility contact", AccessibilityStatus: "Accessibility status",
|
||||
AccessibilityTechTitle: "Technical approach", AccessibilityTechText: "The application uses semantic HTML, keyboard-operable controls and language metadata. This statement is not a certification. The operator remains responsible for testing the deployed service, custom content, branding and integrations against the requirements applicable to it.",
|
||||
NoticeTitle: "Important", Notice: "These pages are configurable operator information, not legal advice. Their completeness and legal applicability depend on the operator, jurisdiction, business model and actual technical deployment.",
|
||||
}
|
||||
|
||||
sections := []legalSection{
|
||||
{Title: "Provider and contact", Fields: compactFields([]legalField{
|
||||
{Label: "Name / company", Value: requiredValue(cfg.LegalName)},
|
||||
{Label: "Service address", Value: requiredValue(cfg.LegalAddress)},
|
||||
{Label: "Represented by", Value: cfg.LegalRepresentative},
|
||||
{Label: "Email", Value: requiredValue(cfg.LegalEmail), URL: mailto(cfg.LegalEmail)},
|
||||
{Label: "Telephone", Value: cfg.LegalPhone, URL: tel(cfg.LegalPhone)},
|
||||
})},
|
||||
{Title: "Registration and tax details", Fields: compactFields([]legalField{
|
||||
{Label: "Register / court", Value: cfg.LegalRegister}, {Label: "Registration number", Value: cfg.LegalRegisterNumber}, {Label: "VAT ID", Value: cfg.LegalVATID},
|
||||
}), Paragraphs: []string{"Data is only displayed if the operator actually has relevant data to provide. The software is generally not released for profit-oriented purposes."}},
|
||||
switch normalizeLanguage(lang) {
|
||||
case "de":
|
||||
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Impressum", "Datenschutz", "Barrierefreiheit"
|
||||
base.LegalTitle, base.LegalLead = "Impressum und Anbieterinformationen", "Angaben zum Betreiber und zu den Kontaktmöglichkeiten dieses Dienstes."
|
||||
base.Operator, base.Representative, base.Contact = "Anbieter / Betreiber", "Vertreten durch", "Kontakt"
|
||||
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Anschrift", "E-Mail", "Telefon", "Register", "Registernummer", "Umsatzsteuer-ID"
|
||||
base.Editorial, base.EditorialAddress = "Journalistisch-redaktionell verantwortlich", "Anschrift der verantwortlichen Person"
|
||||
base.DisputeTitle = "Verbraucherstreitbeilegung"
|
||||
base.DisputeNA = "Der Betreiber hat die allgemeine Informationspflicht zur Verbraucherstreitbeilegung für dieses Angebot als nicht anwendbar eingeordnet."
|
||||
base.DisputeNo = "Der Betreiber erklärt, nicht an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen, soweit keine gesetzliche Verpflichtung besteht."
|
||||
base.DisputeYes = "Der Betreiber erklärt, an Streitbeilegungsverfahren vor der nachfolgend genannten Verbraucherschlichtungsstelle teilzunehmen."
|
||||
base.PrivacyTitle, base.PrivacyLead = "Datenschutzhinweise", "Informationen zum Betrieb dieses Dienstes und zur Verarbeitung technischer Verbindungsdaten."
|
||||
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Verantwortlicher / Betreiber", "Datenschutzkontakt", "Hosting-Anbieter", "Anschrift des Hosting-Anbieters"
|
||||
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Server-Protokolle", "Speicherdauer der Protokolle", "Empfänger / Auftragsverarbeiter", "Drittlandübermittlungen", "Aufsichtsbehörde"
|
||||
base.PrivacyTechTitle = "Technische Ausgestaltung"
|
||||
base.PrivacyTechText = "Die Anwendung selbst benötigt keine Werbetracker oder Drittanbieter-Analytics. Je nach Deployment können Reverse Proxy, Hosting-Plattform oder umgebende Infrastruktur Verbindungs- und Protokolldaten verarbeiten. Der Betreiber muss die Angaben auf dieser Seite mit dem tatsächlichen Betrieb abgleichen."
|
||||
base.AccessibilityTitle, base.AccessibilityLead = "Barrierefreiheit", "Informationen und Kontaktmöglichkeiten zur Barrierefreiheit dieses Dienstes."
|
||||
base.AccessibilityContact, base.AccessibilityStatus = "Kontakt zur Barrierefreiheit", "Stand der Barrierefreiheit"
|
||||
base.AccessibilityTechTitle = "Technischer Ansatz"
|
||||
base.AccessibilityTechText = "Die Anwendung verwendet semantisches HTML, tastaturbedienbare Steuerelemente und Sprachmetadaten. Diese Angabe ist keine Zertifizierung. Der Betreiber bleibt dafür verantwortlich, das konkrete Deployment, eigene Inhalte, Branding und Integrationen anhand der für ihn geltenden Anforderungen zu prüfen."
|
||||
base.NoticeTitle, base.Notice = "Wichtiger Hinweis", "Diese Seiten stellen konfigurierbare Betreiberinformationen und keine Rechtsberatung dar. Vollständigkeit und rechtliche Anwendbarkeit hängen vom Betreiber, der Rechtsordnung, dem Geschäftsmodell und dem tatsächlichen technischen Betrieb ab."
|
||||
case "fr":
|
||||
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Mentions légales", "Confidentialité", "Accessibilité"
|
||||
base.LegalTitle, base.LegalLead = "Mentions légales", "Informations sur l’exploitant et les moyens de contact de ce service."
|
||||
base.Operator, base.Representative, base.Contact = "Exploitant", "Représenté par", "Contact"
|
||||
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adresse", "E-mail", "Téléphone", "Registre", "Numéro d’enregistrement", "Numéro de TVA"
|
||||
base.Editorial, base.EditorialAddress = "Responsabilité éditoriale", "Adresse de la personne responsable"
|
||||
base.DisputeTitle, base.DisputeNA = "Règlement des litiges de consommation", "L’exploitant a considéré que l’obligation générale d’information relative au règlement des litiges de consommation n’est pas applicable à ce service."
|
||||
base.DisputeNo = "L’exploitant déclare ne pas participer à une procédure de règlement des litiges de consommation, sauf obligation légale contraire."
|
||||
base.DisputeYes = "L’exploitant déclare participer à une procédure de règlement des litiges devant l’organisme indiqué ci-dessous."
|
||||
base.PrivacyTitle, base.PrivacyLead = "Informations sur la confidentialité", "Informations sur l’exploitation du service et le traitement des données techniques de connexion."
|
||||
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsable / exploitant", "Contact protection des données", "Hébergeur", "Adresse de l’hébergeur"
|
||||
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Journaux serveur", "Durée de conservation des journaux", "Destinataires / sous-traitants", "Transferts vers des pays tiers", "Autorité de contrôle"
|
||||
base.PrivacyTechTitle, base.PrivacyTechText = "Conception technique", "L’application elle-même ne nécessite ni traceurs publicitaires ni outils d’analyse tiers. Selon le déploiement, le proxy inverse, l’hébergement ou l’infrastructure environnante peuvent traiter des données de connexion et de journalisation. L’exploitant doit maintenir ces informations conformes au fonctionnement réel."
|
||||
base.AccessibilityTitle, base.AccessibilityLead = "Accessibilité", "Informations et contact concernant l’accessibilité de ce service."
|
||||
base.AccessibilityContact, base.AccessibilityStatus = "Contact accessibilité", "État d’accessibilité"
|
||||
base.AccessibilityTechTitle, base.AccessibilityTechText = "Approche technique", "L’application utilise du HTML sémantique, des contrôles utilisables au clavier et des métadonnées de langue. Cette indication n’est pas une certification. L’exploitant reste responsable de l’évaluation du déploiement, des contenus personnalisés, de l’identité visuelle et des intégrations."
|
||||
base.NoticeTitle, base.Notice = "Remarque importante", "Ces pages fournissent des informations configurables sur l’exploitant et ne constituent pas un conseil juridique. Leur exhaustivité dépend du déploiement et du droit applicable."
|
||||
case "es":
|
||||
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Aviso legal", "Privacidad", "Accesibilidad"
|
||||
base.LegalTitle, base.LegalLead = "Aviso legal", "Información sobre el operador y los medios de contacto de este servicio."
|
||||
base.Operator, base.Representative, base.Contact = "Operador", "Representado por", "Contacto"
|
||||
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Dirección", "Correo electrónico", "Teléfono", "Registro", "Número registral", "NIF-IVA"
|
||||
base.Editorial, base.EditorialAddress = "Responsabilidad editorial", "Dirección de la persona responsable"
|
||||
base.DisputeTitle, base.DisputeNA = "Resolución de litigios de consumo", "El operador ha considerado que la obligación general de información sobre resolución de litigios de consumo no es aplicable a este servicio."
|
||||
base.DisputeNo = "El operador declara que no participa en procedimientos de resolución de litigios de consumo salvo obligación legal."
|
||||
base.DisputeYes = "El operador declara que participa en procedimientos de resolución de litigios ante el organismo indicado a continuación."
|
||||
base.PrivacyTitle, base.PrivacyLead = "Información de privacidad", "Información sobre la operación del servicio y el tratamiento de datos técnicos de conexión."
|
||||
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsable / operador", "Contacto de protección de datos", "Proveedor de alojamiento", "Dirección del proveedor de alojamiento"
|
||||
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Registros del servidor", "Conservación de registros", "Destinatarios / encargados", "Transferencias a terceros países", "Autoridad de control"
|
||||
base.PrivacyTechTitle, base.PrivacyTechText = "Diseño técnico", "La aplicación no requiere rastreadores publicitarios ni analítica de terceros. Según el despliegue, el proxy inverso, el alojamiento o la infraestructura circundante pueden procesar datos de conexión y registros. El operador debe mantener esta información alineada con el funcionamiento real."
|
||||
base.AccessibilityTitle, base.AccessibilityLead = "Accesibilidad", "Información y contacto sobre la accesibilidad de este servicio."
|
||||
base.AccessibilityContact, base.AccessibilityStatus = "Contacto de accesibilidad", "Estado de accesibilidad"
|
||||
base.AccessibilityTechTitle, base.AccessibilityTechText = "Enfoque técnico", "La aplicación utiliza HTML semántico, controles operables mediante teclado y metadatos de idioma. Esta información no es una certificación. El operador sigue siendo responsable de evaluar el despliegue, el contenido personalizado, la marca y las integraciones."
|
||||
base.NoticeTitle, base.Notice = "Aviso importante", "Estas páginas contienen información configurable del operador y no constituyen asesoramiento jurídico. Su suficiencia depende del despliegue y de la normativa aplicable."
|
||||
case "it":
|
||||
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Note legali", "Privacy", "Accessibilità"
|
||||
base.LegalTitle, base.LegalLead = "Note legali", "Informazioni sul gestore e sui contatti di questo servizio."
|
||||
base.Operator, base.Representative, base.Contact = "Gestore", "Rappresentato da", "Contatto"
|
||||
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Indirizzo", "E-mail", "Telefono", "Registro", "Numero di registrazione", "Partita IVA"
|
||||
base.Editorial, base.EditorialAddress = "Responsabilità editoriale", "Indirizzo del responsabile editoriale"
|
||||
base.DisputeTitle, base.DisputeNA = "Risoluzione delle controversie dei consumatori", "Il gestore ha valutato come non applicabile a questo servizio l’obbligo generale di informazione sulla risoluzione delle controversie dei consumatori."
|
||||
base.DisputeNo = "Il gestore dichiara di non partecipare a procedure di risoluzione delle controversie dei consumatori salvo diverso obbligo di legge."
|
||||
base.DisputeYes = "Il gestore dichiara di partecipare a procedure di risoluzione delle controversie dinanzi all’organismo indicato di seguito."
|
||||
base.PrivacyTitle, base.PrivacyLead = "Informativa privacy", "Informazioni sul funzionamento del servizio e sul trattamento dei dati tecnici di connessione."
|
||||
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Titolare / gestore", "Contatto privacy", "Fornitore di hosting", "Indirizzo del fornitore di hosting"
|
||||
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Log del server", "Conservazione dei log", "Destinatari / responsabili", "Trasferimenti verso paesi terzi", "Autorità di controllo"
|
||||
base.PrivacyTechTitle, base.PrivacyTechText = "Configurazione tecnica", "L’applicazione non richiede tracker pubblicitari o analytics di terze parti. A seconda del deployment, reverse proxy, hosting o infrastruttura circostante possono trattare dati di connessione e log. Il gestore deve mantenere queste informazioni coerenti con il funzionamento effettivo."
|
||||
base.AccessibilityTitle, base.AccessibilityLead = "Accessibilità", "Informazioni e contatti relativi all’accessibilità di questo servizio."
|
||||
base.AccessibilityContact, base.AccessibilityStatus = "Contatto accessibilità", "Stato di accessibilità"
|
||||
base.AccessibilityTechTitle, base.AccessibilityTechText = "Approccio tecnico", "L’applicazione utilizza HTML semantico, controlli utilizzabili da tastiera e metadati linguistici. Questa indicazione non è una certificazione. Il gestore resta responsabile della verifica del deployment, dei contenuti personalizzati, del branding e delle integrazioni."
|
||||
base.NoticeTitle, base.Notice = "Avviso importante", "Queste pagine contengono informazioni configurabili del gestore e non costituiscono consulenza legale. La loro completezza dipende dal deployment e dalla normativa applicabile."
|
||||
case "nl":
|
||||
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Juridische informatie", "Privacy", "Toegankelijkheid"
|
||||
base.LegalTitle, base.LegalLead = "Juridische informatie", "Informatie over de exploitant en contactmogelijkheden van deze dienst."
|
||||
base.Operator, base.Representative, base.Contact = "Exploitant", "Vertegenwoordigd door", "Contact"
|
||||
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adres", "E-mail", "Telefoon", "Register", "Registratienummer", "Btw-nummer"
|
||||
base.Editorial, base.EditorialAddress = "Redactionele verantwoordelijkheid", "Adres van de redactioneel verantwoordelijke"
|
||||
base.DisputeTitle, base.DisputeNA = "Consumentengeschillen", "De exploitant heeft beoordeeld dat de algemene informatieplicht over consumentengeschillen niet van toepassing is op deze dienst."
|
||||
base.DisputeNo = "De exploitant verklaart niet deel te nemen aan procedures voor consumentengeschillen, tenzij dit wettelijk verplicht is."
|
||||
base.DisputeYes = "De exploitant verklaart deel te nemen aan procedures voor consumentengeschillen bij de hieronder genoemde instantie."
|
||||
base.PrivacyTitle, base.PrivacyLead = "Privacy-informatie", "Informatie over de werking van de dienst en verwerking van technische verbindingsgegevens."
|
||||
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Verwerkingsverantwoordelijke / exploitant", "Privacycontact", "Hostingprovider", "Adres van hostingprovider"
|
||||
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Serverlogs", "Bewaartermijn logs", "Ontvangers / verwerkers", "Doorgiften naar derde landen", "Toezichthouder"
|
||||
base.PrivacyTechTitle, base.PrivacyTechText = "Technische inrichting", "De toepassing zelf vereist geen advertentietrackers of externe analytics. Afhankelijk van de deployment kunnen reverse proxy, hostingplatform of omliggende infrastructuur verbindings- en loggegevens verwerken. De exploitant moet deze informatie laten aansluiten op de werkelijke werking."
|
||||
base.AccessibilityTitle, base.AccessibilityLead = "Toegankelijkheid", "Informatie en contact over de toegankelijkheid van deze dienst."
|
||||
base.AccessibilityContact, base.AccessibilityStatus = "Contact toegankelijkheid", "Toegankelijkheidsstatus"
|
||||
base.AccessibilityTechTitle, base.AccessibilityTechText = "Technische aanpak", "De toepassing gebruikt semantische HTML, toetsenbordbedienbare besturingselementen en taalmetadata. Dit is geen certificering. De exploitant blijft verantwoordelijk voor het toetsen van deployment, aangepaste inhoud, huisstijl en integraties."
|
||||
base.NoticeTitle, base.Notice = "Belangrijk", "Deze pagina’s bevatten configureerbare exploitantinformatie en zijn geen juridisch advies. Volledigheid hangt af van deployment en toepasselijk recht."
|
||||
case "pt":
|
||||
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Informação legal", "Privacidade", "Acessibilidade"
|
||||
base.LegalTitle, base.LegalLead = "Informação legal", "Informações sobre o operador e os contactos deste serviço."
|
||||
base.Operator, base.Representative, base.Contact = "Operador", "Representado por", "Contacto"
|
||||
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Morada", "E-mail", "Telefone", "Registo", "Número de registo", "N.º de IVA"
|
||||
base.Editorial, base.EditorialAddress = "Responsabilidade editorial", "Morada da pessoa editorialmente responsável"
|
||||
base.DisputeTitle, base.DisputeNA = "Resolução de litígios de consumo", "O operador avaliou que a obrigação geral de informação sobre resolução de litígios de consumo não se aplica a este serviço."
|
||||
base.DisputeNo = "O operador declara que não participa em procedimentos de resolução de litígios de consumo, salvo obrigação legal."
|
||||
base.DisputeYes = "O operador declara que participa em procedimentos de resolução de litígios perante a entidade indicada abaixo."
|
||||
base.PrivacyTitle, base.PrivacyLead = "Informação de privacidade", "Informações sobre o funcionamento do serviço e o tratamento de dados técnicos de ligação."
|
||||
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Responsável / operador", "Contacto de proteção de dados", "Fornecedor de alojamento", "Morada do fornecedor de alojamento"
|
||||
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Registos do servidor", "Conservação dos registos", "Destinatários / subcontratantes", "Transferências para países terceiros", "Autoridade de controlo"
|
||||
base.PrivacyTechTitle, base.PrivacyTechText = "Configuração técnica", "A aplicação não necessita de rastreadores publicitários nem de analytics de terceiros. Dependendo do deployment, o proxy inverso, o alojamento ou a infraestrutura envolvente podem tratar dados de ligação e registos. O operador deve manter esta informação alinhada com o funcionamento real."
|
||||
base.AccessibilityTitle, base.AccessibilityLead = "Acessibilidade", "Informações e contacto sobre a acessibilidade deste serviço."
|
||||
base.AccessibilityContact, base.AccessibilityStatus = "Contacto de acessibilidade", "Estado de acessibilidade"
|
||||
base.AccessibilityTechTitle, base.AccessibilityTechText = "Abordagem técnica", "A aplicação utiliza HTML semântico, controlos operáveis por teclado e metadados de idioma. Esta indicação não é uma certificação. O operador continua responsável por testar o deployment, os conteúdos personalizados, a marca e as integrações."
|
||||
base.NoticeTitle, base.Notice = "Aviso importante", "Estas páginas contêm informações configuráveis do operador e não constituem aconselhamento jurídico. A suficiência depende do deployment e da legislação aplicável."
|
||||
case "pl":
|
||||
base.LegalLabel, base.PrivacyLabel, base.AccessibilityLabel = "Informacje prawne", "Prywatność", "Dostępność"
|
||||
base.LegalTitle, base.LegalLead = "Informacje prawne", "Informacje o operatorze i możliwościach kontaktu z usługą."
|
||||
base.Operator, base.Representative, base.Contact = "Operator", "Reprezentowany przez", "Kontakt"
|
||||
base.Address, base.Email, base.Phone, base.Register, base.RegisterNo, base.VAT = "Adres", "E-mail", "Telefon", "Rejestr", "Numer rejestracyjny", "NIP UE"
|
||||
base.Editorial, base.EditorialAddress = "Odpowiedzialność redakcyjna", "Adres osoby odpowiedzialnej redakcyjnie"
|
||||
base.DisputeTitle, base.DisputeNA = "Rozwiązywanie sporów konsumenckich", "Operator ocenił, że ogólny obowiązek informacyjny dotyczący rozwiązywania sporów konsumenckich nie ma zastosowania do tej usługi."
|
||||
base.DisputeNo = "Operator oświadcza, że nie uczestniczy w postępowaniach dotyczących pozasądowego rozwiązywania sporów konsumenckich, chyba że obowiązek taki wynika z prawa."
|
||||
base.DisputeYes = "Operator oświadcza, że uczestniczy w postępowaniach dotyczących rozwiązywania sporów przed wskazanym poniżej podmiotem."
|
||||
base.PrivacyTitle, base.PrivacyLead = "Informacje o prywatności", "Informacje o działaniu usługi i przetwarzaniu technicznych danych połączenia."
|
||||
base.Controller, base.DataProtection, base.Hosting, base.HostingAddress = "Administrator / operator", "Kontakt ds. ochrony danych", "Dostawca hostingu", "Adres dostawcy hostingu"
|
||||
base.Logs, base.Retention, base.Recipients, base.Transfers, base.Authority = "Logi serwera", "Okres przechowywania logów", "Odbiorcy / podmioty przetwarzające", "Transfery do państw trzecich", "Organ nadzorczy"
|
||||
base.PrivacyTechTitle, base.PrivacyTechText = "Rozwiązania techniczne", "Sama aplikacja nie wymaga trackerów reklamowych ani zewnętrznych narzędzi analitycznych. W zależności od wdrożenia reverse proxy, hosting lub infrastruktura otaczająca mogą przetwarzać dane połączeń i logi. Operator musi dostosować informacje na tej stronie do rzeczywistego działania systemu."
|
||||
base.AccessibilityTitle, base.AccessibilityLead = "Dostępność", "Informacje i kontakt w sprawie dostępności tej usługi."
|
||||
base.AccessibilityContact, base.AccessibilityStatus = "Kontakt ds. dostępności", "Stan dostępności"
|
||||
base.AccessibilityTechTitle, base.AccessibilityTechText = "Podejście techniczne", "Aplikacja wykorzystuje semantyczny HTML, elementy obsługiwane klawiaturą oraz metadane językowe. Informacja ta nie stanowi certyfikacji. Operator nadal odpowiada za sprawdzenie konkretnego wdrożenia, własnych treści, identyfikacji wizualnej i integracji."
|
||||
base.NoticeTitle, base.Notice = "Ważna informacja", "Te strony zawierają konfigurowalne informacje operatora i nie stanowią porady prawnej. Ich kompletność zależy od wdrożenia i właściwego prawa."
|
||||
}
|
||||
if cfg.EditorialResponsibleName != "" || cfg.EditorialResponsibleAddress != "" {
|
||||
sections = append(sections, legalSection{Title: "Editorial responsibility", Paragraphs: []string{"Responsible for journalistic-editorial content where applicable under German media law:"}, Fields: compactFields([]legalField{{Label: "Name", Value: cfg.EditorialResponsibleName}, {Label: "Address", Value: cfg.EditorialResponsibleAddress}})})
|
||||
}
|
||||
if dispute := disputeSection(cfg, false); dispute.Title != "" {
|
||||
sections = append(sections, dispute)
|
||||
}
|
||||
sections = append(sections, legalSection{Title: "Content and disclosure notices", Paragraphs: []string{
|
||||
"The service creates technical transparency notices from the information supplied. It does not automatically determine whether a particular disclosure is legally required, complete or accurate.",
|
||||
"The publishing person or organisation remains responsible for declarations, editorial content and linked material.",
|
||||
}})
|
||||
return legalPage{Title: "Legal notice", MetaDescription: "Provider identification and contact details.", Eyebrow: "Legal information", Intro: "Operator information for this digital service.", Warning: legalWarning(missing, false), Sections: sections, LastUpdated: "20 July 2026"}
|
||||
return base
|
||||
}
|
||||
|
||||
func privacyPage(cfg Config, lang string) legalPage {
|
||||
contact := firstNonEmpty(cfg.DataProtectionContact, cfg.LegalEmail)
|
||||
missing := missingNamed(cfg, map[string]string{
|
||||
"LEGAL_NAME": cfg.LegalName, "LEGAL_ADDRESS": cfg.LegalAddress, "LEGAL_EMAIL": cfg.LegalEmail,
|
||||
"HOSTING_PROVIDER": cfg.HostingProvider, "LOG_RETENTION": cfg.LogRetention,
|
||||
})
|
||||
if lang == "de" {
|
||||
sections := []legalSection{
|
||||
{
|
||||
Title: "1. Verantwortlicher",
|
||||
Fields: compactFields([]legalField{
|
||||
{Label: "Verantwortlicher", Value: requiredValue(cfg.LegalName)},
|
||||
{Label: "Anschrift", Value: requiredValue(cfg.LegalAddress)},
|
||||
{Label: "Datenschutzkontakt", Value: requiredValue(contact), URL: mailto(contact)},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Title: "2. Aufruf der Website und Server-Protokolle",
|
||||
Paragraphs: []string{
|
||||
"Beim Aufruf werden technisch erforderliche Verbindungsdaten verarbeitet. Dazu können IP-Adresse, Zeitpunkt, angeforderter Pfad, HTTP-Methode, Statuscode, übertragene Datenmenge, Referrer und User-Agent gehören. Welche Daten der vorgeschaltete Hosting- oder Proxy-Dienst tatsächlich protokolliert, muss der Betreiber anhand seiner Infrastruktur prüfen.",
|
||||
"Die Anwendung selbst protokolliert standardmäßig Methode, Pfad ohne Query-String, Statuscode, Datenmenge, Dauer und eine Request-ID. Die Client-IP wird nur protokolliert, wenn LOG_CLIENT_IP ausdrücklich aktiviert ist.",
|
||||
"Zwecke sind die sichere und stabile Bereitstellung, Fehleranalyse und Missbrauchsabwehr. Rechtsgrundlage ist regelmäßig Art. 6 Abs. 1 lit. f DSGVO; bei vertraglicher Nutzung kann zusätzlich Art. 6 Abs. 1 lit. b DSGVO einschlägig sein.",
|
||||
},
|
||||
Fields: []legalField{{Label: "Vorgesehene Aufbewahrungsdauer", Value: requiredValue(cfg.LogRetention)}},
|
||||
},
|
||||
{
|
||||
Title: "3. Eingaben, URLs und öffentliche Erklärungen",
|
||||
Paragraphs: []string{
|
||||
"Der Generator arbeitet zustandslos und speichert Eingaben nicht in einer Anwendungsdatenbank. Angaben werden jedoch als URL-Parameter verarbeitet und können dadurch im Browser-Verlauf, in Proxy- oder Zugriffsprotokollen sowie bei Weitergabe des erzeugten Links sichtbar werden.",
|
||||
"Erzeugte Erklärungs- und JSON-LD-URLs sind zur öffentlichen Einbindung bestimmt. Deshalb dürfen keine vertraulichen Informationen, besonderen Kategorien personenbezogener Daten oder unnötigen personenbezogenen Angaben in Freitextfelder und URLs eingetragen werden.",
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "4. Cookies, Tracking und lokale Speicherung",
|
||||
Paragraphs: []string{
|
||||
"Die mitgelieferte Weboberfläche setzt keine Cookies, verwendet kein Webtracking und speichert keine Daten in Local Storage oder Session Storage. Wird die Anwendung um Analyse-, Marketing-, Schrift-, Karten-, Video- oder andere Drittinhalte erweitert, muss die Datenschutzerklärung angepasst und eine gegebenenfalls erforderliche Einwilligung vor dem Zugriff auf das Endgerät eingeholt werden.",
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "5. Hosting und Empfänger",
|
||||
Fields: compactFields([]legalField{
|
||||
{Label: "Hosting-Anbieter", Value: requiredValue(cfg.HostingProvider)},
|
||||
{Label: "Anschrift / Region", Value: cfg.HostingAddress},
|
||||
{Label: "Weitere Empfänger", Value: cfg.DataRecipients},
|
||||
{Label: "Drittlandübermittlungen", Value: cfg.ThirdCountryTransfers},
|
||||
}),
|
||||
Paragraphs: []string{"Mit Hosting- und sonstigen Auftragsverarbeitern sind, soweit erforderlich, Verträge nach Art. 28 DSGVO abzuschließen. Übermittlungen in Drittländer dürfen nur auf einer tragfähigen Rechtsgrundlage erfolgen."},
|
||||
},
|
||||
{
|
||||
Title: "6. Optionale Lizenzprüfung",
|
||||
Paragraphs: []string{
|
||||
"Im Offline-Modus findet keine Online-Lizenzprüfung statt. Im Hybrid- oder Online-Modus übermittelt der Lizenz-Client an den konfigurierten Lizenzserver insbesondere Lizenz-Token, Produktkennung, öffentliche Basis-URL, Host, optionale Instanz-ID und Client-Version. Betreiber müssen den eingesetzten Lizenzserver, die Rollenverteilung, Speicherdauer und Rechtsgrundlage gesondert dokumentieren.",
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "7. Rechte betroffener Personen",
|
||||
Paragraphs: []string{"Betroffene Personen haben nach Maßgabe der DSGVO insbesondere Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch. Erteilte Einwilligungen können mit Wirkung für die Zukunft widerrufen werden. Außerdem besteht ein Beschwerderecht bei einer Datenschutzaufsichtsbehörde."},
|
||||
Fields: compactFields([]legalField{{Label: "Zuständige oder benannte Aufsichtsbehörde", Value: cfg.SupervisoryAuthorityName, URL: cfg.SupervisoryAuthorityURL}}),
|
||||
},
|
||||
{
|
||||
Title: "8. Sicherheit und Änderungen",
|
||||
Paragraphs: []string{
|
||||
"Die Anwendung verwendet unter anderem restriktive Browser-Sicherheitsrichtlinien, Größenlimits für JSON-Anfragen, sichere Standard-Containeroptionen und minimierte Protokollierung.",
|
||||
},
|
||||
},
|
||||
}
|
||||
return legalPage{Title: "Datenschutzerklärung", MetaDescription: "Informationen zur Verarbeitung personenbezogener Daten.", Eyebrow: "Datenschutz", Intro: "Diese Vorlage beschreibt den technischen Standardzustand der Anwendung. Weitere Informationen entnehmen Sie den Angaben auf dieser Seite.", Warning: legalWarning(missing, true), Sections: sections, LastUpdated: "20. Juli 2026"}
|
||||
func normalizeLanguage(lang string) string {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
if i := strings.IndexByte(lang, '-'); i >= 0 {
|
||||
lang = lang[:i]
|
||||
}
|
||||
|
||||
sections := []legalSection{
|
||||
{Title: "1. Controller", Fields: compactFields([]legalField{{Label: "Controller", Value: requiredValue(cfg.LegalName)}, {Label: "Address", Value: requiredValue(cfg.LegalAddress)}, {Label: "Privacy contact", Value: requiredValue(contact), URL: mailto(contact)}})},
|
||||
{Title: "2. Website access and server logs", Paragraphs: []string{
|
||||
"Technically necessary connection data may be processed when the service is accessed, including IP address, time, requested path, HTTP method, status, transferred bytes, referrer and user agent. For more information, please refer to the information on this page.",
|
||||
"By default, the application logs method, path without query string, status, bytes, duration and a request ID. Client IP logging is disabled unless LOG_CLIENT_IP is explicitly enabled.",
|
||||
"Purposes are secure and reliable operation, troubleshooting and abuse prevention. The legal basis is generally Article 6(1)(f) GDPR and, where relevant, Article 6(1)(b) GDPR.",
|
||||
}, Fields: []legalField{{Label: "Intended retention period", Value: requiredValue(cfg.LogRetention)}}},
|
||||
{Title: "3. Inputs, URLs and public declarations", Paragraphs: []string{
|
||||
"The generator is stateless and does not store inputs in an application database. Inputs are nevertheless processed as URL parameters and may appear in browser history and proxy or access logs.",
|
||||
"Generated declaration and JSON-LD URLs are designed for public embedding. Do not enter confidential data, special-category personal data or unnecessary personal information.",
|
||||
}},
|
||||
{Title: "4. Cookies and tracking", Paragraphs: []string{"The bundled interface sets no cookies, uses no web tracking and does not store data in Local Storage or Session Storage. Operators adding analytics, marketing or third-party embeds must update this notice and obtain any legally required consent before accessing the user's device."}},
|
||||
{Title: "5. Hosting and recipients", Fields: compactFields([]legalField{{Label: "Hosting provider", Value: requiredValue(cfg.HostingProvider)}, {Label: "Address / region", Value: cfg.HostingAddress}, {Label: "Other recipients", Value: cfg.DataRecipients}, {Label: "Third-country transfers", Value: cfg.ThirdCountryTransfers}})},
|
||||
{Title: "6. Optional licence validation", Paragraphs: []string{"Offline mode performs no online licence validation. Hybrid and online modes send the licence token, product identifier, public base URL, host, optional instance ID and client version to the configured licence server. The operator must document that service separately."}},
|
||||
{Title: "7. Data-subject rights", Paragraphs: []string{"Subject to the GDPR, individuals may have rights of access, rectification, erasure, restriction, portability, objection and withdrawal of consent, as well as the right to complain to a supervisory authority."}, Fields: compactFields([]legalField{{Label: "Supervisory authority", Value: cfg.SupervisoryAuthorityName, URL: cfg.SupervisoryAuthorityURL}})},
|
||||
{Title: "8. Security and changes", Paragraphs: []string{"The application includes restrictive browser policies, JSON request-size limits, hardened container defaults and minimised logging."}},
|
||||
}
|
||||
return legalPage{Title: "Privacy notice", MetaDescription: "Information about personal-data processing.", Eyebrow: "Privacy", Intro: "This template describes the application's default technical behaviour. The actual operator must adapt it to hosting, logs, contracts and added services.", Warning: legalWarning(missing, false), Sections: sections, LastUpdated: "20 July 2026"}
|
||||
return lang
|
||||
}
|
||||
|
||||
func accessibilityPage(cfg Config, lang string) legalPage {
|
||||
contact := firstNonEmpty(cfg.AccessibilityContact, cfg.LegalEmail)
|
||||
missing := missingNamed(cfg, map[string]string{"ACCESSIBILITY_CONTACT or LEGAL_EMAIL": contact})
|
||||
if lang == "de" {
|
||||
status := cfg.AccessibilityStatus
|
||||
if status == "" {
|
||||
status = "Noch nicht durch eine unabhängige Stelle geprüft; derzeit keine vollständige Konformitätserklärung."
|
||||
}
|
||||
return legalPage{
|
||||
Title: "Barrierefreiheit", MetaDescription: "Hinweise zur barrierefreien Nutzung und Kontakt für Rückmeldungen.", Eyebrow: "Zugänglichkeit",
|
||||
Intro: "Wir möchten, dass der Generator, die Erklärungsseiten und die Rechtstexte möglichst vielen Menschen zugänglich sind.",
|
||||
Warning: legalWarning(missing, true), LastUpdated: "20. Juli 2026",
|
||||
Sections: []legalSection{
|
||||
{Title: "Stand der Vereinbarkeit", Paragraphs: []string{status, "Ob das Barrierefreiheitsstärkungsgesetz oder andere verbindliche Anforderungen auf ein konkretes Angebot anwendbar sind, muss der jeweilige Betreiber anhand seines Geschäftsmodells und seiner Zielgruppe prüfen."}},
|
||||
{Title: "Umgesetzte Maßnahmen", Bullets: []string{
|
||||
"semantische Überschriften, Beschriftungen und Tabellenstrukturen",
|
||||
"Bedienbarkeit der Kernfunktionen per Tastatur",
|
||||
"sichtbare Fokusmarkierungen und responsive Darstellung",
|
||||
"Textalternativen für erzeugte Badge-Vorschauen",
|
||||
"keine zwingenden Animationen, Cookies oder Tracking-Dialoge",
|
||||
}},
|
||||
{Title: "Bekannte Grenzen", Bullets: []string{
|
||||
"Es liegt noch kein vollständiger Audit nach EN 301 549 oder WCAG 2.2 vor.",
|
||||
"Breite Datentabellen können auf kleinen Bildschirmen horizontales Scrollen erfordern.",
|
||||
"Die Verständlichkeit automatisch erzeugter Erklärungen hängt von den eingegebenen Angaben ab.",
|
||||
"Eingebettete Badges und Erklärungen müssen auch auf der einbindenden Website barrierefrei beschriftet und positioniert werden.",
|
||||
}},
|
||||
{Title: "Rückmeldung und Kontakt", Paragraphs: []string{"Bitte melden Sie Barrieren mit URL, Gerät, Browser, assistiver Technologie und einer kurzen Beschreibung. Wir prüfen nachvollziehbare Hinweise und bemühen uns um eine geeignete Lösung."}, Fields: []legalField{{Label: "Kontakt", Value: requiredValue(contact), URL: mailto(contact)}}},
|
||||
{Title: "Rechtlicher Kontext", Paragraphs: []string{"Das BFSG gilt seit dem 28. Juni 2025 für bestimmte Produkte und Dienstleistungen für Verbraucher. Nicht jede Website fällt automatisch darunter. Für erfasste Angebote sind die gesetzlichen Informations- und Barrierefreiheitsanforderungen gesondert zu erfüllen."}, Fields: []legalField{{Label: "Bundesfachstelle Barrierefreiheit", Value: "Informationen zum BFSG", URL: "https://www.bundesfachstelle-barrierefreiheit.de/DE/Barrierefreiheitsstaerkungsgesetz"}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
status := cfg.AccessibilityStatus
|
||||
if status == "" {
|
||||
status = "Not yet independently audited; no claim of full conformance is made."
|
||||
}
|
||||
return legalPage{
|
||||
Title: "Accessibility", MetaDescription: "Accessibility status and feedback contact.", Eyebrow: "Accessibility",
|
||||
Intro: "We aim to make the generator, declaration pages and legal information accessible to as many people as possible.",
|
||||
Warning: legalWarning(missing, false), LastUpdated: "20 July 2026",
|
||||
Sections: []legalSection{
|
||||
{Title: "Conformance status", Paragraphs: []string{status, "The operator must assess whether the German Accessibility Strengthening Act or other binding rules apply to its specific offering."}},
|
||||
{Title: "Measures implemented", Bullets: []string{"semantic headings, labels and table structures", "keyboard access to core functions", "visible focus indicators and responsive layout", "text alternatives for generated badge previews", "no mandatory animations, cookie or tracking dialogs"}},
|
||||
{Title: "Known limitations", Bullets: []string{"No complete EN 301 549 or WCAG 2.2 audit has yet been completed.", "Wide data tables may require horizontal scrolling on small screens.", "The clarity of generated declarations depends on the information entered.", "Embedded badges must also be labelled and positioned accessibly on the host website."}},
|
||||
{Title: "Feedback", Paragraphs: []string{"Please report barriers with the URL, device, browser, assistive technology and a brief description."}, Fields: []legalField{{Label: "Contact", Value: requiredValue(contact), URL: mailto(contact)}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func disputeSection(cfg Config, german bool) legalSection {
|
||||
switch cfg.ConsumerDisputeStatus {
|
||||
case "not_applicable":
|
||||
return legalSection{}
|
||||
case "not_participating":
|
||||
if german {
|
||||
return legalSection{Title: "Verbraucherstreitbeilegung", Paragraphs: []string{"Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."}}
|
||||
}
|
||||
return legalSection{Title: "Consumer dispute resolution", Paragraphs: []string{"We are neither willing nor obliged to participate in dispute-resolution proceedings before a consumer arbitration body."}}
|
||||
case "participating":
|
||||
if german {
|
||||
return legalSection{Title: "Verbraucherstreitbeilegung", Paragraphs: []string{"Wir nehmen an Streitbeilegungsverfahren vor der folgenden Verbraucherschlichtungsstelle teil:"}, Fields: []legalField{{Label: "Schlichtungsstelle", Value: cfg.ConsumerDisputeBody, URL: cfg.ConsumerDisputeURL}}}
|
||||
}
|
||||
return legalSection{Title: "Consumer dispute resolution", Paragraphs: []string{"We participate in dispute-resolution proceedings before the following consumer arbitration body:"}, Fields: []legalField{{Label: "Arbitration body", Value: cfg.ConsumerDisputeBody, URL: cfg.ConsumerDisputeURL}}}
|
||||
func legalLabel(lang, kind string) string {
|
||||
copy := legalLabels(lang)
|
||||
switch kind {
|
||||
case "privacy":
|
||||
return copy.PrivacyLabel
|
||||
case "accessibility":
|
||||
return copy.AccessibilityLabel
|
||||
default:
|
||||
return legalSection{}
|
||||
return copy.LegalLabel
|
||||
}
|
||||
}
|
||||
|
||||
func requiredValue(value string) string {
|
||||
if configValueMissing(value) {
|
||||
return "[NICHT KONFIGURIERT / NOT CONFIGURED]"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func compactFields(in []legalField) []legalField {
|
||||
out := make([]legalField, 0, len(in))
|
||||
for _, field := range in {
|
||||
if strings.TrimSpace(field.Value) != "" {
|
||||
out = append(out, field)
|
||||
func (s *Server) operatorPage(lang, kind string) legalPage {
|
||||
c := legalLabels(lang)
|
||||
nonEmpty := func(label, value string) []legalItem {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return []legalItem{{Label: label, Value: value}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func missingNamed(cfg Config, fields map[string]string) []string {
|
||||
_ = cfg
|
||||
var missing []string
|
||||
for key, value := range fields {
|
||||
if configValueMissing(value) {
|
||||
missing = append(missing, key)
|
||||
appendItem := func(items []legalItem, label, value, link string) []legalItem {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return items
|
||||
}
|
||||
return append(items, legalItem{Label: label, Value: value, URL: link})
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "privacy":
|
||||
operator := []legalItem{}
|
||||
operator = appendItem(operator, c.Controller, s.cfg.LegalName, "")
|
||||
operator = appendItem(operator, c.Address, s.cfg.LegalAddress, "")
|
||||
operator = appendItem(operator, c.Email, s.cfg.LegalEmail, mailto(s.cfg.LegalEmail))
|
||||
operator = appendItem(operator, c.DataProtection, s.cfg.DataProtectionContact, mailtoIfEmail(s.cfg.DataProtectionContact))
|
||||
hosting := []legalItem{}
|
||||
hosting = appendItem(hosting, c.Hosting, s.cfg.HostingProvider, "")
|
||||
hosting = appendItem(hosting, c.HostingAddress, s.cfg.HostingAddress, "")
|
||||
processing := []legalItem{}
|
||||
processing = appendItem(processing, c.Retention, s.cfg.LogRetention, "")
|
||||
processing = appendItem(processing, c.Recipients, s.cfg.DataRecipients, "")
|
||||
processing = appendItem(processing, c.Transfers, s.cfg.ThirdCountryTransfers, "")
|
||||
processing = appendItem(processing, c.Authority, s.cfg.SupervisoryAuthorityName, s.cfg.SupervisoryAuthorityURL)
|
||||
sections := []legalSection{{Title: c.Controller, Items: operator}, {Title: c.Hosting, Items: hosting}, {Title: c.PrivacyTechTitle, Paragraphs: []string{c.PrivacyTechText}, Items: processing}}
|
||||
return legalPage{Kind: kind, Title: c.PrivacyTitle, Lead: c.PrivacyLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice}
|
||||
case "accessibility":
|
||||
items := []legalItem{}
|
||||
items = appendItem(items, c.AccessibilityContact, s.cfg.AccessibilityContact, mailtoIfEmail(s.cfg.AccessibilityContact))
|
||||
items = appendItem(items, c.AccessibilityStatus, s.cfg.AccessibilityStatus, "")
|
||||
sections := []legalSection{{Title: c.AccessibilityTitle, Items: items}, {Title: c.AccessibilityTechTitle, Paragraphs: []string{c.AccessibilityTechText}}}
|
||||
return legalPage{Kind: kind, Title: c.AccessibilityTitle, Lead: c.AccessibilityLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice}
|
||||
default:
|
||||
operator := []legalItem{}
|
||||
operator = appendItem(operator, c.Operator, s.cfg.LegalName, "")
|
||||
operator = appendItem(operator, c.Address, s.cfg.LegalAddress, "")
|
||||
operator = appendItem(operator, c.Representative, s.cfg.LegalRepresentative, "")
|
||||
operator = appendItem(operator, c.Email, s.cfg.LegalEmail, mailto(s.cfg.LegalEmail))
|
||||
operator = appendItem(operator, c.Phone, s.cfg.LegalPhone, tel(s.cfg.LegalPhone))
|
||||
operator = appendItem(operator, c.Register, s.cfg.LegalRegister, "")
|
||||
operator = appendItem(operator, c.RegisterNo, s.cfg.LegalRegisterNumber, "")
|
||||
operator = appendItem(operator, c.VAT, s.cfg.LegalVATID, "")
|
||||
sections := []legalSection{{Title: c.Operator, Items: operator}}
|
||||
if strings.TrimSpace(s.cfg.EditorialResponsibleName) != "" || strings.TrimSpace(s.cfg.EditorialResponsibleAddress) != "" {
|
||||
items := []legalItem{}
|
||||
items = appendItem(items, c.Editorial, s.cfg.EditorialResponsibleName, "")
|
||||
items = appendItem(items, c.EditorialAddress, s.cfg.EditorialResponsibleAddress, "")
|
||||
sections = append(sections, legalSection{Title: c.Editorial, Items: items})
|
||||
}
|
||||
disputeText := c.DisputeNA
|
||||
if s.cfg.ConsumerDisputeStatus == "not_participating" {
|
||||
disputeText = c.DisputeNo
|
||||
} else if s.cfg.ConsumerDisputeStatus == "participating" {
|
||||
disputeText = c.DisputeYes
|
||||
}
|
||||
disputeItems := nonEmpty(c.DisputeTitle, s.cfg.ConsumerDisputeBody)
|
||||
if len(disputeItems) > 0 && strings.TrimSpace(s.cfg.ConsumerDisputeURL) != "" {
|
||||
disputeItems[0].URL = s.cfg.ConsumerDisputeURL
|
||||
}
|
||||
sections = append(sections, legalSection{Title: c.DisputeTitle, Paragraphs: []string{disputeText}, Items: disputeItems})
|
||||
return legalPage{Kind: "legal", Title: c.LegalTitle, Lead: c.LegalLead, Sections: sections, NoticeTitle: c.NoticeTitle, Notice: c.Notice}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
return missing
|
||||
}
|
||||
|
||||
func legalWarning(missing []string, german bool) string {
|
||||
if len(missing) == 0 {
|
||||
return ""
|
||||
func (s *Server) handleOperatorPage(kind string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
lang := s.language(r)
|
||||
data := pageData{
|
||||
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Text: i18n.Get(lang).Text,
|
||||
Languages: i18nLanguages(), LegalPage: s.operatorPage(lang, kind), License: s.licenses.Status(),
|
||||
WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel),
|
||||
}
|
||||
s.renderHTML(w, "operator.html", data)
|
||||
}
|
||||
if german {
|
||||
return fmt.Sprintf("Diese Seite ist noch nicht produktionsreif. Folgende Betreiberangaben fehlen: %s.", strings.Join(missing, ", "))
|
||||
}
|
||||
return fmt.Sprintf("This page is not ready for production. Missing operator configuration: %s.", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
func i18nLanguages() []i18n.LanguageOption { return i18n.Languages() }
|
||||
|
||||
func mailto(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || !strings.Contains(value, "@") || strings.ContainsAny(value, "\r\n") {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return "mailto:" + value
|
||||
}
|
||||
|
||||
func mailtoIfEmail(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.Contains(value, "@") && !strings.ContainsAny(value, " \t\r\n") {
|
||||
return "mailto:" + value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func tel(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.ContainsAny(value, "\r\n") {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "", "/", "")
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ProductID = "ai-disclosure-standard"
|
||||
ProductVersion = "1.6.3"
|
||||
FeatureCustomText = "custom_text"
|
||||
FeatureCustomBadge = "custom_badge"
|
||||
FeatureWhiteLabel = "white_label"
|
||||
ProductID = "ai-disclosure-standard"
|
||||
ProductVersion = "2.0.2"
|
||||
FeatureCustomText = "custom_text"
|
||||
FeatureCustomBadge = "custom_badge"
|
||||
FeatureWhiteLabel = "white_label"
|
||||
FeatureBulkAPI = "bulk_api"
|
||||
FeatureExportBundle = "export_bundle"
|
||||
)
|
||||
|
||||
//go:embed trusted_keys.json
|
||||
|
||||
@@ -13,6 +13,9 @@ type metrics struct {
|
||||
badgeRenders atomic.Uint64
|
||||
validationRequests atomic.Uint64
|
||||
validationFailures atomic.Uint64
|
||||
bulkRequests atomic.Uint64
|
||||
bulkItems atomic.Uint64
|
||||
bulkFailures atomic.Uint64
|
||||
panics atomic.Uint64
|
||||
}
|
||||
|
||||
@@ -36,6 +39,15 @@ func (m *metrics) serveHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
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_bulk_requests_total Total bulk API requests.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_bulk_requests_total counter\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_bulk_requests_total %d\n", m.bulkRequests.Load())
|
||||
fmt.Fprintf(w, "# HELP ai_disclosure_bulk_items_total Total items submitted to the bulk API.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_bulk_items_total counter\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_bulk_items_total %d\n", m.bulkItems.Load())
|
||||
fmt.Fprintf(w, "# HELP ai_disclosure_bulk_failures_total Rejected or malformed bulk API requests.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_bulk_failures_total counter\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_bulk_failures_total %d\n", m.bulkFailures.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())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,9 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/b1tsblog/license-platform/sdk/go/licenseclient"
|
||||
)
|
||||
|
||||
func testHandler(t *testing.T) http.Handler {
|
||||
@@ -38,7 +36,7 @@ func TestPresetBadgeFrench(t *testing.T) {
|
||||
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") {
|
||||
if !strings.Contains(w.Body.String(), "AI ASSISTED") {
|
||||
t.Fatal("missing localized preset")
|
||||
}
|
||||
}
|
||||
@@ -67,7 +65,7 @@ func TestCommunityRejectsCustomBadge(t *testing.T) {
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "pro_feature_required") {
|
||||
if !strings.Contains(w.Body.String(), "licensed_feature_required") {
|
||||
t.Fatalf("unexpected response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -84,30 +82,6 @@ func TestCapabilities(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicLicenseRedactsInternalMetadata(t *testing.T) {
|
||||
status := publicLicense(licenseclient.Status{
|
||||
Edition: "pro", Licensed: true, LicenseID: "lic-secret", Customer: "Customer Name", Product: "product",
|
||||
Features: []string{"custom_text"}, Limits: map[string]int64{"seats": 3}, ExpiresAt: "2030-01-01T00:00:00Z",
|
||||
Mode: "online", Source: "server", LastChecked: "2026-07-20T12:00:00Z", LeaseExpires: "2026-07-21T12:00:00Z",
|
||||
Reason: "internal diagnostic", ServerURL: "https://licenses.internal.example",
|
||||
})
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(data)
|
||||
for _, forbidden := range []string{"lic-secret", "Customer Name", "licenses.internal.example", "internal diagnostic", "lastChecked", "leaseExpiresAt", `"mode"`, `"source"`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("public licence status leaks %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
for _, expected := range []string{`"edition":"pro"`, `"licensed":true`, `"custom_text"`, `"seats":3`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("public licence status missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketingPageGerman(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/product?lang=de", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -116,7 +90,7 @@ func TestMarketingPageGerman(t *testing.T) {
|
||||
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"} {
|
||||
for _, expected := range []string{"Declare how content was made.", "Docker Compose", "JSON-LD"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("marketing page missing %q", expected)
|
||||
}
|
||||
@@ -131,7 +105,7 @@ func TestMarketingPageLanguageNegotiation(t *testing.T) {
|
||||
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") {
|
||||
if !strings.Contains(w.Body.String(), "Declare how content was made.") {
|
||||
t.Fatal("missing French marketing content")
|
||||
}
|
||||
}
|
||||
@@ -209,7 +183,7 @@ func TestGeneratorExposesAssuranceSelection(t *testing.T) {
|
||||
`value="technicallyRecorded"`,
|
||||
`value="signed"`,
|
||||
`value="verified"`,
|
||||
`Nachweisgrundlage`,
|
||||
`Declaration Status`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("generator missing %q", expected)
|
||||
@@ -227,7 +201,7 @@ func TestDeclarationUsesSelectedAssurance(t *testing.T) {
|
||||
body := w.Body.String()
|
||||
for _, expected := range []string{
|
||||
`"assurance":"technicallyRecorded"`,
|
||||
`Als Nachweisgrundlage ist eine technische Protokollierung im Erstellungs- oder Veröffentlichungsprozess angegeben.`,
|
||||
`Status: Declared; die Angaben sind zusätzlich technisch protokolliert. Daraus folgt keine automatische Verifikation.`,
|
||||
`<colgroup>`,
|
||||
`scope="col"`,
|
||||
} {
|
||||
@@ -249,51 +223,6 @@ func TestSignedAssuranceExplainsScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorShowsAIACTGuardrails(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/?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{
|
||||
`id="legal-deepfake"`,
|
||||
`id="legal-public-interest"`,
|
||||
`id="legal-creative"`,
|
||||
`id="responsible-role"`,
|
||||
`id="responsible"`,
|
||||
`id="responsible-url"`,
|
||||
"formale Prüfung",
|
||||
"Platzierung nach Art. 50",
|
||||
"erfüllt aber nicht automatisch die Anbieterpflicht",
|
||||
"Emoji-Variante",
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("generator missing AI Act guardrail %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarationExplainsReviewAndMachineMarkingLimits(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/declaration?preset=full&review=editorial&lang=de&responsible=Beispielredaktion&responsibleUrl=https%3A%2F%2Fexample.org%2Fimpressum", 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{
|
||||
"Beispielredaktion",
|
||||
"begründet für sich allein keine Ausnahme",
|
||||
"wirksame, zuverlässige, robuste und interoperable maschinenlesbare Markierung",
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("declaration missing AI Act guardrail %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundPageGerman(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/background?lang=de", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -303,9 +232,9 @@ func TestBackgroundPageGerman(t *testing.T) {
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, expected := range []string{
|
||||
"Warum KI-Nutzung gekennzeichnet wird",
|
||||
"Artikel 50 gilt ab 2. August 2026",
|
||||
"keine allgemeine Kennzeichnungspflicht",
|
||||
"Content needs context.",
|
||||
"Regulatorischer Kontext: EU AI Act, Artikel 50",
|
||||
"UCNG ist kein offizielles System der Europäischen Union",
|
||||
"KI-generierter Text zu Angelegenheiten von öffentlichem Interesse",
|
||||
"Leitlinien zu Transparenzpflichten",
|
||||
`hreflang="en"`,
|
||||
@@ -325,7 +254,7 @@ func TestBackgroundPageFrenchNegotiation(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Pourquoi signaler l’utilisation de l’IA") {
|
||||
if !strings.Contains(w.Body.String(), "Content needs context.") {
|
||||
t.Fatal("missing French background content")
|
||||
}
|
||||
}
|
||||
@@ -344,234 +273,157 @@ func TestNavigationUsesInternalBackgroundPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalPagesUseConfiguredOperatorData(t *testing.T) {
|
||||
cfg := Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de",
|
||||
LegalName: "Beispiel GmbH", LegalAddress: "Musterstraße 1\n10115 Berlin", LegalEmail: "datenschutz@example.org",
|
||||
HostingProvider: "Beispiel Hosting GmbH", LogRetention: "7 Tage", ConsumerDisputeStatus: "not_participating",
|
||||
}
|
||||
h := testHandlerConfig(t, cfg)
|
||||
for _, tc := range []struct {
|
||||
path, expected string
|
||||
}{
|
||||
{"/impressum?lang=de", "Beispiel GmbH"},
|
||||
{"/datenschutz?lang=de", "Die Client-IP wird nur protokolliert"},
|
||||
{"/barrierefreiheit?lang=de", "Noch nicht durch eine unabhängige Stelle geprüft"},
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s returned %d: %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), tc.expected) {
|
||||
t.Fatalf("%s missing %q", tc.path, tc.expected)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `/impressum?lang=de`) || !strings.Contains(w.Body.String(), `/datenschutz?lang=de`) {
|
||||
t.Fatalf("%s missing legal footer links", tc.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalStrictRejectsMissingConfiguration(t *testing.T) {
|
||||
_, err := New(context.Background(), Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", LegalStrict: true,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err == nil || !strings.Contains(err.Error(), "LEGAL_NAME") {
|
||||
t.Fatalf("expected strict legal configuration error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAndRequestIDValidation(t *testing.T) {
|
||||
cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", EnableHSTS: true}
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("X-Request-ID", "invalid request id\n")
|
||||
w := httptest.NewRecorder()
|
||||
testHandlerConfig(t, cfg).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
if !strings.Contains(csp, "script-src 'self' 'nonce-") || strings.Contains(csp, "'unsafe-inline'") {
|
||||
t.Fatalf("unexpected CSP %q", csp)
|
||||
}
|
||||
if got := w.Header().Get("Strict-Transport-Security"); !strings.Contains(got, "max-age=") || strings.Contains(strings.ToLower(got), "includesubdomains") {
|
||||
t.Fatalf("unexpected HSTS: %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-Request-ID"); got == "" || strings.Contains(got, " ") {
|
||||
t.Fatalf("invalid response request id %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsDisabledByDefaultAndTokenProtected(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("disabled metrics returned %d", w.Code)
|
||||
}
|
||||
|
||||
cfg := Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true, MetricsToken: "a-long-random-test-token"}
|
||||
h := testHandlerConfig(t, cfg)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unprotected metrics returned %d", w.Code)
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
r.Header.Set("Authorization", "Bearer a-long-random-test-token")
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "ai_disclosure_http_requests_total") {
|
||||
t.Fatalf("protected metrics returned %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsCannotBeEnabledWithoutToken(t *testing.T) {
|
||||
_, err := New(context.Background(), Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", MetricsEnabled: true,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err == nil || !strings.Contains(err.Error(), "METRICS_TOKEN") {
|
||||
t.Fatalf("expected metrics token validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBooleanEnvironmentValueFailsClosed(t *testing.T) {
|
||||
t.Setenv("LEGAL_STRICT", "definitely")
|
||||
cfg := ConfigFromEnv()
|
||||
_, err := New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err == nil || !strings.Contains(err.Error(), "LEGAL_STRICT must be true or false") {
|
||||
t.Fatalf("expected environment validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalEmailMustBePlainAddress(t *testing.T) {
|
||||
_, err := New(context.Background(), Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", LegalEmail: "Name <legal@example.org>",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err == nil || !strings.Contains(err.Error(), "LEGAL_EMAIL") {
|
||||
t.Fatalf("expected legal email validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalURLsMustUseHTTPOrHTTPS(t *testing.T) {
|
||||
_, err := New(context.Background(), Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", ContactURL: "javascript:alert(1)",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err == nil || !strings.Contains(err.Error(), "CONTACT_URL") {
|
||||
t.Fatalf("expected contact URL validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsOversizedBody(t *testing.T) {
|
||||
body := `{"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsNonJSONContentType(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(`{}`))
|
||||
r.Header.Set("Content-Type", "text/plain")
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPUsesNearestUntrustedForwardedAddress(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "https://example.org/", nil)
|
||||
r.RemoteAddr = "10.0.0.2:443"
|
||||
r.Header.Set("X-Forwarded-For", "198.51.100.200, 203.0.113.50, 10.0.0.3")
|
||||
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||
if got := clientIP(r, true, trusted); got != "203.0.113.50" {
|
||||
t.Fatalf("unexpected client IP %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustProxyRequiresExplicitCIDRs(t *testing.T) {
|
||||
_, err := New(context.Background(), Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", TrustProxy: true,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err == nil || !strings.Contains(err.Error(), "TRUSTED_PROXY_CIDRS") {
|
||||
t.Fatalf("expected trusted proxy validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiThemeBadgeEndpoint(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/badge/summary.svg?lang=de&theme=emoji", 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{
|
||||
`width="64" height="64"`,
|
||||
`data-theme="emoji"`,
|
||||
`data-icon="summary"`,
|
||||
`fill="#b7791f"`,
|
||||
`Inhaltliche Zusammenfassung`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("emoji badge missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorOffersEmojiTheme(t *testing.T) {
|
||||
func TestGeneratorExposesArticle50Context(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/?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())
|
||||
}
|
||||
const expected = `<option value="emoji">Emoji (quadratisch)</option>`
|
||||
if !strings.Contains(w.Body.String(), expected) {
|
||||
t.Fatalf("generator missing %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicInterestBadgeUsesExplicitDisclosureWithoutExemption(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?mode=article&textExtent=full&textReview=none&legalContext=publicInterestText&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())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "KI-generierte / bearbeitete Inhalte") {
|
||||
t.Fatalf("missing explicit legal disclosure wording: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicInterestBadgeCanRemainVoluntaryWithSubstantiveReviewAndResponsibility(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?mode=article&textExtent=partial&textReview=expert&legalContext=publicInterestText&responsible=Beispielredaktion&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())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Artikeltransparenz") {
|
||||
t.Fatalf("expected voluntary article wording where possible exception is documented: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepfakeDeclarationShowsCautiousAssessment(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&imageExtent=full&imageReview=none&legalContext=deepfake&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{"Vorsichtige Art.-50-Einschätzung", "spricht vieles dafür", "Deepfake / realitätsähnliche KI-Manipulation"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("missing %q: %s", expected, body)
|
||||
for _, expected := range []string{
|
||||
`id="public-interest-text"`, `id="deepfake"`, `id="substantial-review"`,
|
||||
`id="editorial-responsibility-confirmed"`, `id="first-exposure-disclosure"`,
|
||||
`Optionaler regulatorischer Kontext (EU AI Act, Artikel 50)`,
|
||||
} {
|
||||
if !strings.Contains(w.Body.String(), expected) {
|
||||
t.Fatalf("generator missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticle50AssessmentEndpoint(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/article50-assessment.json?preset=full&lang=de&publicInterestText=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"code":"public_interest_text_disclosure_relevant"`, `"potentiallyApplicable":true`, `Nicht bindende technische Entscheidungshilfe`} {
|
||||
if !strings.Contains(w.Body.String(), expected) {
|
||||
t.Fatalf("assessment response missing %q: %s", expected, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegulatoryDeclarationUsesExplicitBadge(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?mode=article&textExtent=full&textReview=none&lang=de&publicInterestText=true", 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 !strings.Contains(w.Body.String(), "AI GENERATED") || !strings.Contains(w.Body.String(), `fill="#b45309"`) {
|
||||
t.Fatalf("regulatory badge not explicit/amber: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarationRendersRegulatoryAssessment(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&textExtent=partial&textReview=expert&lang=de&publicInterestText=true&substantialHumanReview=true&editorialResponsibilityConfirmed=true&responsible=Example%20Redaktion", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`Regulatorische Einordnung`, `Mögliche Ausnahme für redaktionell kontrollierten Text`, `Example Redaktion`, `"regulatoryContext"`} {
|
||||
if !strings.Contains(w.Body.String(), expected) {
|
||||
t.Fatalf("declaration missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityRejectsBulkAPI(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/bulk/declarations", strings.NewReader(`{"items":[{"parameters":{"preset":"research","lang":"de"}}]}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "bulk_api") {
|
||||
t.Fatalf("unexpected bulk response %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkModeRequiresLicensedCapabilityForReadiness(t *testing.T) {
|
||||
h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", ServiceMode: "bulk"})
|
||||
r := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("bulk readiness status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
r = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("bulk workspace status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Bulk Workspace") || !strings.Contains(w.Body.String(), "bulk_api") {
|
||||
t.Fatalf("bulk workspace missing expected content: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkAPIKeyAuthorization(t *testing.T) {
|
||||
s := &Server{cfg: Config{BulkRequireAPIKey: true, BulkAPIKey: "very-secret"}}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/bulk/declarations", nil)
|
||||
if s.bulkAuthorized(r) {
|
||||
t.Fatal("request without API key must not be authorized")
|
||||
}
|
||||
|
||||
r = httptest.NewRequest(http.MethodPost, "/v1/bulk/declarations", nil)
|
||||
r.Header.Set("Authorization", "Bearer very-secret")
|
||||
if !s.bulkAuthorized(r) {
|
||||
t.Fatal("bearer API key should be authorized")
|
||||
}
|
||||
|
||||
r = httptest.NewRequest(http.MethodPost, "/v1/bulk/declarations", nil)
|
||||
r.Header.Set("X-API-Key", "very-secret")
|
||||
if !s.bulkAuthorized(r) {
|
||||
t.Fatal("X-API-Key should be authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireLicenseFailsClosedForApplicationRequests(t *testing.T) {
|
||||
h := testHandlerConfig(t, Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", RequireLicense: true,
|
||||
})
|
||||
|
||||
for _, path := range []string{"/", "/v1/validate"} {
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if path == "/v1/validate" {
|
||||
r = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("%s status %d, want 503", path, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("health status %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurableAPICORS(t *testing.T) {
|
||||
h := testHandlerConfig(t, Config{
|
||||
ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de", APIAllowedOrigin: "https://publisher.example",
|
||||
})
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/capabilities", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://publisher.example" {
|
||||
t.Fatalf("CORS origin = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedURLsCanTargetSeparatePublicInstance(t *testing.T) {
|
||||
s := &Server{cfg: Config{BaseURL: "http://bulk.internal", OutputBaseURL: "https://ai.example.org"}}
|
||||
declarationURL, manifestURL, badgeURL := s.generatedURLs(url.Values{"lang": {"de"}})
|
||||
for _, got := range []string{declarationURL, manifestURL, badgeURL} {
|
||||
if !strings.HasPrefix(got, "https://ai.example.org/") {
|
||||
t.Fatalf("generated URL %q does not use OUTPUT_BASE_URL", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
"leaseKeys": {
|
||||
"fUt_sPkz_FdLBKL0KnPAydlflls-PyJaViEbNCRjRIJipGwiHz-E7mPYzrnY8YotKt-aom8L61YlJEYksUZMQg": "gM8FdzFWGCfffjbPj9_2Ao8n5agKdvbRkf8xMWXdk5c"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,148 @@ func Build(lang string) Page {
|
||||
page = pages()["en"]
|
||||
}
|
||||
page.Sources = officialSources(lang)
|
||||
return page
|
||||
return applyUCNGPositioning(page, lang)
|
||||
}
|
||||
|
||||
func applyUCNGPositioning(p Page, lang string) Page {
|
||||
switch lang {
|
||||
case "de":
|
||||
p.MetaDescription = "Hintergrund zu UCNG Content Declarations, Content-Transparenz und optionalem regulatorischem Kontext nach dem EU AI Act."
|
||||
p.Eyebrow, p.Title = "UCNG · Content Transparency", "Content needs context."
|
||||
p.Lead = "Digitale Inhalte entstehen heute auf unterschiedliche Weise: Menschen schreiben, Systeme unterstützen, Modelle generieren und Werkzeuge transformieren. UCNG schafft eine gemeinsame Sprache, mit der Inhalte ihren deklarierten Entstehungs- und Transformationskontext transparent machen können."
|
||||
p.StatusKicker, p.StatusTitle = "Grundprinzip", "UCNG deklariert Herkunft. UCNG bewertet keine Wahrheit."
|
||||
p.StatusText = "Eine UCNG Declaration beschreibt, wie ein Inhalt nach den bereitgestellten Angaben entstanden oder verändert worden ist. Sie ist kein Fact-Check, kein KI-Detektor und nicht automatisch eine Verifikation, Zertifizierung oder Feststellung rechtlicher Compliance."
|
||||
p.ContentsTitle = "Auf dieser Seite"
|
||||
p.Contents = []string{"Warum Content Kontext braucht", "Das UCNG Declaration System", "Regulatorischer Kontext: EU AI Act", "Declaration vs Verification", "Rolle dieser Referenzimplementierung", "Offizielle Quellen"}
|
||||
p.OverviewTitle = "Warum Content Kontext braucht"
|
||||
p.OverviewParagraphs = []string{
|
||||
"Das Ergebnis eines digitalen Inhalts zeigt häufig nicht, welche Menschen, Werkzeuge oder generativen Systeme an seiner Entstehung beteiligt waren. UCNG setzt deshalb nicht bei einer Bewertung des Inhalts an, sondern beim deklarierten Entstehungskontext.",
|
||||
"Die Leitidee lautet: Content should declare how it was made. Eine Declaration soll denselben Kontext sowohl für Menschen als auch in strukturierter, maschinenlesbarer Form verfügbar machen.",
|
||||
"UCNG ist bewusst plattform-, medien-, anbieter- und jurisdiktionsunabhängig gedacht. Text, Bild, Audio, Video, Code und multimodale Inhalte können nach derselben Grundidee beschrieben werden.",
|
||||
}
|
||||
p.ScopeTitle = "Das UCNG Declaration System"
|
||||
p.ScopeLead = "UCNG beschreibt Rollen und Prozesse statt vermeintlich exakter KI-Prozentwerte. Eine vollständige Declaration kann mehrere Dimensionen verbinden."
|
||||
p.Obligations = []Card{
|
||||
{Kicker: "Origin", Title: "Woher stammt der wesentliche Inhalt?", Description: "Die Herkunft kann beispielsweise als Human, AI, Mixed oder Unknown beschrieben werden."},
|
||||
{Kicker: "AI Role", Title: "Welche Rolle hatte KI?", Description: "Die Rolle wird als None, Assist, Transform oder Generate beschrieben, statt einen schwer objektivierbaren Prozentwert zu behaupten."},
|
||||
{Kicker: "Human Review", Title: "Welche menschliche Prüfung fand statt?", Description: "Eine Declaration kann unterscheiden, ob keine Prüfung, Review, Bearbeitung oder redaktionelle Verantwortung dokumentiert ist."},
|
||||
{Kicker: "Source & Provenance", Title: "Wer deklariert – und welche Herkunftsdaten existieren?", Description: "Declaration Source und Provenance schaffen zusätzlichen Kontext, ohne automatisch eine Verifikation zu behaupten."},
|
||||
}
|
||||
p.ArticleTitle = "Regulatorischer Kontext: EU AI Act, Artikel 50"
|
||||
p.ArticleLead = "UCNG ist kein offizielles System der Europäischen Union und keine Compliance-Zertifizierung. Artikel 50 ist ein möglicher regulatorischer Kontext, in dem interoperable, sichtbare und maschinenlesbare Transparenzinformationen relevant sein können."
|
||||
// Existing scenario details are retained because they describe the optional
|
||||
// Article 50 decision-support context of this implementation.
|
||||
p.RequirementsTitle = "Declaration ist nicht Verification"
|
||||
p.RequirementsLead = "Die UCNG Guidelines unterscheiden terminologisch zwischen einer abgegebenen Erklärung und einer zusätzlichen Prüfung. Diese Ebenen dürfen nicht synonym verwendet werden."
|
||||
p.Principles = []Card{
|
||||
{Kicker: "Declared", Title: "Eine Angabe wurde deklariert", Description: "Jemand hat Informationen zum Entstehungsprozess bereitgestellt. Das ist die Grundbedeutung einer UCNG Declaration."},
|
||||
{Kicker: "Attested", Title: "Eine identifizierbare Stelle übernimmt Verantwortung", Description: "Attestation bedeutet mehr als eine bloße Declaration und muss als eigene Ebene kenntlich gemacht werden."},
|
||||
{Kicker: "Verified", Title: "Bestimmte Angaben wurden überprüft", Description: "Verified darf nur verwendet werden, wenn konkrete Angaben technisch oder organisatorisch nach einem dokumentierten Verfahren geprüft wurden."},
|
||||
{Kicker: "Nicht impliziert", Title: "Kein Wahrheits- oder Compliance-Siegel", Description: "Ein UCNG Badge bedeutet nicht automatisch true, safe, fact checked, copyright cleared, EU compliant oder certified."},
|
||||
}
|
||||
p.ProjectTitle = "Wie diese Referenzimplementierung einzuordnen ist"
|
||||
p.ProjectParagraphs = []string{
|
||||
"Diese Anwendung setzt die UCNG-Idee praktisch um: Aus strukturierten Angaben entstehen eine menschenlesbare Declaration, ein kompaktes Badge und eine maschinenlesbare JSON-LD-Darstellung. Ein gemeinsamer Link kann die Angaben für einzelne Inhalte oder mehrere Content-Bestandteile zusammenführen.",
|
||||
"Die aktuelle technische Schema-Version der Anwendung wird durch diese Textfassung nicht verändert. Deshalb werden keine neuen Datenklassen künstlich eingeführt. Die formale UCNG Specification und ihre Versionierung sollten – wie in den Guidelines vorgesehen – getrennt vom Branding und von dieser Referenzimplementierung weiterentwickelt werden.",
|
||||
}
|
||||
p.ProjectItems = []string{
|
||||
"Human-readable Declaration und Machine Layer aus denselben Angaben",
|
||||
"Komponentenbezogene Angaben für Text, Bild, Recherche, Übersetzung, Audio, Video und Code",
|
||||
"Kompaktes UCNG Badge als Einstieg in die ausführliche Declaration",
|
||||
"Optionaler regulatorischer Kontext getrennt von der UCNG-Kernaussage",
|
||||
"Keine automatische Verifikation, Zertifizierung, Wahrheitsbewertung oder Feststellung rechtlicher Compliance",
|
||||
}
|
||||
p.SourcesTitle = "Regulatorischer Kontext: offizielle EU-Quellen"
|
||||
p.SourcesLead = "Die folgenden Quellen gehören zum optionalen EU-AI-Act-Kontext dieser Anwendung. Sie definieren UCNG nicht und machen UCNG nicht zu einem offiziellen System der Europäischen Union."
|
||||
p.DisclaimerTitle = "Wichtiger Hinweis"
|
||||
p.DisclaimerText = "UCNG ist eine unabhängige offene Initiative und weder Institution, Behörde noch offizielles Zertifizierungssystem der Europäischen Union. UCNG Declarations informieren über den deklarierten Erstellungs- und Transformationsprozess von Inhalten. Eine UCNG Declaration begründet für sich allein keine Rechtskonformität, Authentizität, Genauigkeit oder Wahrhaftigkeit."
|
||||
p.CTATitle, p.CTALead, p.CTAButton = "Declare how content was made.", "Erstelle eine UCNG Declaration und mache den angegebenen Entstehungskontext für Menschen und Maschinen lesbar.", "Declaration erstellen"
|
||||
p.Footer = "UCNG · Content transparency · Keine Rechtsberatung"
|
||||
case "en":
|
||||
p.MetaDescription = "Background on UCNG content declarations, content transparency and optional EU AI Act regulatory context."
|
||||
p.Eyebrow, p.Title = "UCNG · Content Transparency", "Content needs context."
|
||||
p.Lead = "Digital content is created in many ways: humans write, systems assist, models generate and tools transform. UCNG provides a common language for making the declared creation and transformation context of content transparent."
|
||||
p.StatusKicker, p.StatusTitle = "Core principle", "UCNG declares origin. UCNG does not judge truth."
|
||||
p.StatusText = "A UCNG Declaration describes how content was created or transformed according to the information supplied. It is not a fact-check, AI detector, automatic verification, certification or finding of legal compliance."
|
||||
p.Contents = []string{"Why content needs context", "The UCNG Declaration System", "Regulatory context: EU AI Act", "Declaration vs verification", "Role of this reference implementation", "Official sources"}
|
||||
p.OverviewTitle = "Why content needs context"
|
||||
p.OverviewParagraphs = []string{"The finished content often does not reveal which people, tools or generative systems shaped it. UCNG therefore focuses on the declared creation context rather than judging the content itself.", "The guiding idea is simple: Content should declare how it was made. The same context should be understandable to people and available in a structured machine-readable form.", "UCNG is intended to be platform-, media-, vendor- and jurisdiction-independent. Text, image, audio, video, code and multimodal content can follow the same basic declaration model."}
|
||||
p.ScopeTitle = "The UCNG Declaration System"
|
||||
p.ScopeLead = "UCNG describes roles and processes rather than claiming precise AI percentages. A full declaration can combine several dimensions."
|
||||
p.Obligations = []Card{{Kicker: "Origin", Title: "Where does the substantial content come from?", Description: "Origin can be described as Human, AI, Mixed or Unknown."}, {Kicker: "AI Role", Title: "What role did AI play?", Description: "The role is described as None, Assist, Transform or Generate rather than as a hard-to-define percentage."}, {Kicker: "Human Review", Title: "What human review took place?", Description: "A declaration can distinguish review, editing and editorial responsibility."}, {Kicker: "Source & Provenance", Title: "Who declares, and what provenance exists?", Description: "Declaration source and provenance add context without automatically claiming verification."}}
|
||||
p.ArticleTitle = "Regulatory context: EU AI Act, Article 50"
|
||||
p.ArticleLead = "UCNG is not an official European Union system or a compliance certification. Article 50 is one regulatory context in which interoperable, visible and machine-readable transparency information may be relevant."
|
||||
p.RequirementsTitle = "Declaration is not verification"
|
||||
p.RequirementsLead = "The UCNG Guidelines distinguish terminology for a declaration and for additional checking. These levels must not be treated as synonyms."
|
||||
p.Principles = []Card{{Kicker: "Declared", Title: "Information has been declared", Description: "Someone supplied information about the creation process. This is the basic meaning of a UCNG Declaration."}, {Kicker: "Attested", Title: "An identifiable organisation assumes responsibility", Description: "Attestation is a separate level beyond a declaration and should be identified as such."}, {Kicker: "Verified", Title: "Specified information has been checked", Description: "Verified should only be used when particular claims have been technically or organisationally checked under a documented procedure."}, {Kicker: "Not implied", Title: "No truth or compliance seal", Description: "A UCNG badge does not automatically mean true, safe, fact checked, copyright cleared, EU compliant or certified."}}
|
||||
p.ProjectTitle = "How to understand this reference implementation"
|
||||
p.ProjectParagraphs = []string{"This application implements the UCNG idea in practice: structured inputs generate a human-readable declaration, a compact badge and a machine-readable JSON-LD representation. One shared link can combine information for single or multi-component content.", "This content revision does not change the application's current technical schema. No new data classes are invented in copy. As described in the Guidelines, the formal UCNG Specification and its versioning should evolve separately from branding and this reference implementation."}
|
||||
p.ProjectItems = []string{"Human-readable declaration and machine layer from the same data", "Component-level details for text, image, research, translation, audio, video and code", "Compact UCNG badge as an entry point to the full declaration", "Optional regulatory context kept separate from the core UCNG claim", "No automatic verification, certification, truth assessment or finding of legal compliance"}
|
||||
p.SourcesTitle = "Regulatory context: official EU sources"
|
||||
p.SourcesLead = "The following sources belong to the optional EU AI Act context of this application. They do not define UCNG or make UCNG an official European Union system."
|
||||
p.DisclaimerTitle = "Important notice"
|
||||
p.DisclaimerText = "UCNG is an independent open initiative and is not an institution, agency or official certification scheme of the European Union. UCNG declarations provide information about the declared creation and transformation process of content. A UCNG declaration does not by itself establish legal compliance, authenticity, accuracy or truthfulness."
|
||||
p.CTATitle, p.CTALead, p.CTAButton = "Declare how content was made.", "Create a UCNG Declaration and make the stated creation context readable to people and machines.", "Create a declaration"
|
||||
p.Footer = "UCNG · Content transparency · Not legal advice"
|
||||
default:
|
||||
// Keep detailed legal copy in the existing translation, while moving the
|
||||
// top-level framing and project identity to UCNG.
|
||||
p.Title = "Content needs context."
|
||||
p.StatusTitle = "UCNG does not judge content. UCNG describes how content was created."
|
||||
p.ProjectTitle = "UCNG · Universal Content Notation Guidelines"
|
||||
p.Footer = "UCNG · Universal Content Notation Guidelines"
|
||||
switch lang {
|
||||
case "fr":
|
||||
p.Eyebrow = "UCNG · Transparence des contenus"
|
||||
p.Lead = "UCNG fournit un langage commun pour déclarer comment un contenu numérique a été créé ou transformé, lisible par les personnes et par les machines."
|
||||
p.ProjectParagraphs = []string{"Cette implémentation de référence transforme des informations structurées en UCNG Declaration lisible, badge compact et représentation JSON-LD lisible par machine.", "Le contexte réglementaire reste séparé de la déclaration UCNG. Le logiciel ne constitue ni une certification, ni un fact-check, ni une décision juridique."}
|
||||
p.ProjectItems = []string{"Déclaration lisible par les personnes et couche lisible par machine", "Informations par composant pour texte, image, recherche, traduction, audio, vidéo et code", "Badge UCNG compact donnant accès à la déclaration détaillée", "Contexte réglementaire facultatif séparé de l’énoncé central UCNG", "Aucune vérification, certification ou conclusion de conformité juridique automatique"}
|
||||
p.CTATitle, p.CTALead = "Declare how content was made.", "Créez une UCNG Declaration et rendez le contexte de création déclaré lisible par les personnes et les machines."
|
||||
p.DisclaimerText = "UCNG est une initiative ouverte et indépendante, et non un organisme ni un système officiel de certification de l’Union européenne. Une UCNG Declaration n’établit pas à elle seule la conformité juridique, l’authenticité, l’exactitude ou la véracité."
|
||||
p.CTAButton = "Créer une déclaration"
|
||||
case "es":
|
||||
p.Eyebrow = "UCNG · Transparencia del contenido"
|
||||
p.Lead = "UCNG ofrece un lenguaje común para declarar cómo se creó o transformó contenido digital, comprensible para personas y legible por máquinas."
|
||||
p.ProjectParagraphs = []string{"Esta implementación de referencia convierte datos estructurados en una UCNG Declaration legible, un badge compacto y una representación JSON-LD legible por máquinas.", "El contexto regulatorio se mantiene separado de la declaración UCNG. El software no es una certificación, un fact-check ni una decisión jurídica."}
|
||||
p.ProjectItems = []string{"Declaración legible por personas y capa legible por máquinas", "Datos por componente para texto, imagen, investigación, traducción, audio, vídeo y código", "Badge UCNG compacto como acceso a la declaración detallada", "Contexto regulatorio opcional separado de la afirmación central de UCNG", "Sin verificación, certificación ni determinación automática de cumplimiento legal"}
|
||||
p.CTATitle, p.CTALead = "Declare how content was made.", "Crea una UCNG Declaration y haz que el contexto de creación declarado sea legible para personas y máquinas."
|
||||
p.DisclaimerText = "UCNG es una iniciativa abierta e independiente y no es un organismo ni un sistema oficial de certificación de la Unión Europea. Una UCNG Declaration no establece por sí sola cumplimiento legal, autenticidad, exactitud o veracidad."
|
||||
p.CTAButton = "Crear una declaración"
|
||||
case "it":
|
||||
p.Eyebrow = "UCNG · Trasparenza dei contenuti"
|
||||
p.Lead = "UCNG offre un linguaggio comune per dichiarare come i contenuti digitali sono stati creati o trasformati, comprensibile alle persone e leggibile dalle macchine."
|
||||
p.ProjectParagraphs = []string{"Questa implementazione di riferimento trasforma dati strutturati in una UCNG Declaration leggibile, un badge compatto e una rappresentazione JSON-LD leggibile dalle macchine.", "Il contesto normativo resta separato dalla dichiarazione UCNG. Il software non è una certificazione, un fact-check o una decisione giuridica."}
|
||||
p.ProjectItems = []string{"Dichiarazione leggibile dalle persone e machine layer", "Informazioni per componente per testo, immagine, ricerca, traduzione, audio, video e codice", "Badge UCNG compatto come accesso alla dichiarazione completa", "Contesto normativo opzionale separato dall’affermazione centrale UCNG", "Nessuna verifica, certificazione o determinazione automatica di conformità legale"}
|
||||
p.CTATitle, p.CTALead = "Declare how content was made.", "Crea una UCNG Declaration e rendi il contesto di creazione dichiarato leggibile da persone e macchine."
|
||||
p.DisclaimerText = "UCNG è un’iniziativa aperta e indipendente e non è un organismo né un sistema ufficiale di certificazione dell’Unione europea. Una UCNG Declaration non stabilisce da sola conformità legale, autenticità, accuratezza o veridicità."
|
||||
p.CTAButton = "Crea una dichiarazione"
|
||||
case "nl":
|
||||
p.Eyebrow = "UCNG · Contenttransparantie"
|
||||
p.Lead = "UCNG biedt een gemeenschappelijke taal om te verklaren hoe digitale inhoud is gemaakt of getransformeerd, leesbaar voor mensen en machines."
|
||||
p.ProjectParagraphs = []string{"Deze referentie-implementatie zet gestructureerde gegevens om in een leesbare UCNG Declaration, een compacte badge en een machineleesbare JSON-LD-weergave.", "De regelgevingscontext blijft gescheiden van de UCNG-declaratie. De software is geen certificering, factcheck of juridische beslissing."}
|
||||
p.ProjectItems = []string{"Menselijk leesbare declaratie en machineleesbare laag", "Informatie per component voor tekst, beeld, onderzoek, vertaling, audio, video en code", "Compacte UCNG-badge als toegang tot de volledige declaratie", "Optionele regelgevingscontext gescheiden van de kernclaim van UCNG", "Geen automatische verificatie, certificering of juridische conformiteitsvaststelling"}
|
||||
p.CTATitle, p.CTALead = "Declare how content was made.", "Maak een UCNG Declaration en maak de gedeclareerde creatiecontext leesbaar voor mensen en machines."
|
||||
p.DisclaimerText = "UCNG is een onafhankelijk open initiatief en geen instantie of officieel certificeringsstelsel van de Europese Unie. Een UCNG Declaration stelt op zichzelf geen juridische naleving, authenticiteit, nauwkeurigheid of waarheid vast."
|
||||
p.CTAButton = "Maak een declaratie"
|
||||
case "pt":
|
||||
p.Eyebrow = "UCNG · Transparência de conteúdos"
|
||||
p.Lead = "UCNG oferece uma linguagem comum para declarar como conteúdos digitais foram criados ou transformados, compreensível para pessoas e legível por máquinas."
|
||||
p.ProjectParagraphs = []string{"Esta implementação de referência transforma dados estruturados numa UCNG Declaration legível, num badge compacto e numa representação JSON-LD legível por máquinas.", "O contexto regulamentar permanece separado da declaração UCNG. O software não constitui certificação, fact-check nem decisão jurídica."}
|
||||
p.ProjectItems = []string{"Declaração legível por pessoas e camada legível por máquinas", "Informação por componente para texto, imagem, pesquisa, tradução, áudio, vídeo e código", "Badge UCNG compacto como acesso à declaração completa", "Contexto regulamentar opcional separado da afirmação central da UCNG", "Sem verificação, certificação ou determinação automática de conformidade legal"}
|
||||
p.CTATitle, p.CTALead = "Declare how content was made.", "Crie uma UCNG Declaration e torne o contexto de criação declarado legível para pessoas e máquinas."
|
||||
p.DisclaimerText = "UCNG é uma iniciativa aberta e independente e não é uma instituição nem um sistema oficial de certificação da União Europeia. Uma UCNG Declaration não estabelece, por si só, conformidade legal, autenticidade, exatidão ou veracidade."
|
||||
p.CTAButton = "Criar uma declaração"
|
||||
case "pl":
|
||||
p.Eyebrow = "UCNG · Przejrzystość treści"
|
||||
p.Lead = "UCNG zapewnia wspólny język do deklarowania, jak treści cyfrowe zostały utworzone lub przekształcone, czytelny dla ludzi i maszyn."
|
||||
p.ProjectParagraphs = []string{"Ta implementacja referencyjna przekształca ustrukturyzowane dane w czytelną UCNG Declaration, kompaktowy badge oraz maszynowo czytelną reprezentację JSON-LD.", "Kontekst regulacyjny pozostaje oddzielony od deklaracji UCNG. Oprogramowanie nie jest certyfikacją, fact-checkiem ani decyzją prawną."}
|
||||
p.ProjectItems = []string{"Deklaracja czytelna dla ludzi i warstwa maszynowa", "Informacje według komponentów dla tekstu, obrazu, badań, tłumaczenia, audio, wideo i kodu", "Kompaktowy badge UCNG jako wejście do pełnej deklaracji", "Opcjonalny kontekst regulacyjny oddzielony od głównego komunikatu UCNG", "Brak automatycznej weryfikacji, certyfikacji lub stwierdzenia zgodności prawnej"}
|
||||
p.CTATitle, p.CTALead = "Declare how content was made.", "Utwórz UCNG Declaration i udostępnij deklarowany kontekst tworzenia w formie czytelnej dla ludzi i maszyn."
|
||||
p.DisclaimerText = "UCNG jest niezależną otwartą inicjatywą, a nie instytucją ani oficjalnym systemem certyfikacji Unii Europejskiej. Sama UCNG Declaration nie potwierdza zgodności prawnej, autentyczności, dokładności ani prawdziwości."
|
||||
p.CTAButton = "Utwórz deklarację"
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func normalize(lang string) string {
|
||||
@@ -97,56 +238,56 @@ func officialSources(lang string) []Source {
|
||||
titles := map[string][5]Source{
|
||||
"de": {
|
||||
{Title: "Verordnung (EU) 2024/1689 – AI Act", Description: "Verbindlicher Gesetzestext, insbesondere Artikel 50 zu Transparenzpflichten für bestimmte KI-Systeme.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
|
||||
{Title: "Leitlinien zu Transparenzpflichten", Description: "Von der Europäischen Kommission am 20. Juli 2026 veröffentlichte Leitlinien zum Anwendungsbereich von Artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
|
||||
{Title: "Leitlinien zu Transparenzpflichten", Description: "Von der Europäischen Kommission am 20. Juli 2026 veröffentlichte Leitlinien zum Anwendungsbereich von Artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
|
||||
{Title: "Fragen und Antworten zu Artikel 50", Description: "Praktische Erläuterungen zu Anwendungsbereich, Ausnahmen, menschlicher Prüfung und redaktioneller Kontrolle.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
|
||||
{Title: "Code of Practice zu KI-generierten Inhalten", Description: "Freiwilliges Instrument zur Unterstützung der Umsetzung der Kennzeichnungs- und Markierungspflichten.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
|
||||
{Title: "EU-Icons für KI-generierte Inhalte", Description: "Optionale Symbole der Europäischen Union; ihre Verwendung allein begründet keine Rechtskonformität.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
|
||||
},
|
||||
"en": {
|
||||
{Title: "Regulation (EU) 2024/1689 – AI Act", Description: "Binding legal text, in particular Article 50 on transparency obligations for certain AI systems.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
|
||||
{Title: "Guidelines on transparency obligations", Description: "European Commission guidelines published on 20 July 2026 clarifying the scope of Article 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
|
||||
{Title: "Guidelines on transparency obligations", Description: "European Commission guidelines published on 20 July 2026 clarifying the scope of Article 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
|
||||
{Title: "Questions and answers on Article 50", Description: "Practical explanations of scope, exceptions, human review and editorial control.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
|
||||
{Title: "Code of Practice on AI-generated content", Description: "A voluntary instrument supporting implementation of marking and labelling obligations.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
|
||||
{Title: "EU icons for AI-generated content", Description: "Optional European Union icons; using them alone does not establish legal compliance.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
|
||||
},
|
||||
"fr": {
|
||||
{Title: "Règlement (UE) 2024/1689 – AI Act", Description: "Texte juridique contraignant, notamment l’article 50 relatif aux obligations de transparence de certains systèmes d’IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
|
||||
{Title: "Lignes directrices sur les obligations de transparence", Description: "Lignes directrices de la Commission européenne publiées le 20 juillet 2026 sur le champ d’application de l’article 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
|
||||
{Title: "Lignes directrices sur les obligations de transparence", Description: "Lignes directrices de la Commission européenne publiées le 20 juillet 2026 sur le champ d’application de l’article 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
|
||||
{Title: "Questions et réponses sur l’article 50", Description: "Explications pratiques sur le champ d’application, les exceptions, l’examen humain et le contrôle éditorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
|
||||
{Title: "Code de bonnes pratiques sur les contenus générés par l’IA", Description: "Instrument volontaire destiné à faciliter la mise en œuvre des obligations de marquage et d’étiquetage.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
|
||||
{Title: "Icônes de l’UE pour les contenus générés par l’IA", Description: "Symboles facultatifs de l’Union européenne ; leur utilisation seule ne prouve pas la conformité juridique.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
|
||||
},
|
||||
"es": {
|
||||
{Title: "Reglamento (UE) 2024/1689 – Ley de IA", Description: "Texto jurídico vinculante, en especial el artículo 50 sobre obligaciones de transparencia para determinados sistemas de IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
|
||||
{Title: "Directrices sobre obligaciones de transparencia", Description: "Directrices de la Comisión Europea publicadas el 20 de julio de 2026 sobre el ámbito del artículo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
|
||||
{Title: "Directrices sobre obligaciones de transparencia", Description: "Directrices de la Comisión Europea publicadas el 20 de julio de 2026 sobre el ámbito del artículo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
|
||||
{Title: "Preguntas y respuestas sobre el artículo 50", Description: "Explicaciones prácticas sobre ámbito, excepciones, revisión humana y control editorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
|
||||
{Title: "Código de buenas prácticas sobre contenido generado por IA", Description: "Instrumento voluntario para apoyar la aplicación de las obligaciones de marcado y etiquetado.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
|
||||
{Title: "Iconos de la UE para contenido generado por IA", Description: "Símbolos opcionales de la Unión Europea; su uso por sí solo no acredita el cumplimiento jurídico.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
|
||||
},
|
||||
"it": {
|
||||
{Title: "Regolamento (UE) 2024/1689 – AI Act", Description: "Testo giuridico vincolante, in particolare l’articolo 50 sugli obblighi di trasparenza per determinati sistemi di IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
|
||||
{Title: "Orientamenti sugli obblighi di trasparenza", Description: "Orientamenti della Commissione europea pubblicati il 20 luglio 2026 sull’ambito di applicazione dell’articolo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
|
||||
{Title: "Orientamenti sugli obblighi di trasparenza", Description: "Orientamenti della Commissione europea pubblicati il 20 luglio 2026 sull’ambito di applicazione dell’articolo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
|
||||
{Title: "Domande e risposte sull’articolo 50", Description: "Chiarimenti pratici su ambito, eccezioni, revisione umana e controllo editoriale.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
|
||||
{Title: "Codice di buone pratiche sui contenuti generati dall’IA", Description: "Strumento volontario a sostegno dell’attuazione degli obblighi di marcatura ed etichettatura.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
|
||||
{Title: "Icone UE per i contenuti generati dall’IA", Description: "Simboli facoltativi dell’Unione europea; il loro uso da solo non dimostra la conformità giuridica.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
|
||||
},
|
||||
"nl": {
|
||||
{Title: "Verordening (EU) 2024/1689 – AI-verordening", Description: "Bindende wettekst, met name artikel 50 over transparantieverplichtingen voor bepaalde AI-systemen.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
|
||||
{Title: "Richtsnoeren over transparantieverplichtingen", Description: "Richtsnoeren van de Europese Commissie van 20 juli 2026 over het toepassingsgebied van artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
|
||||
{Title: "Richtsnoeren over transparantieverplichtingen", Description: "Richtsnoeren van de Europese Commissie van 20 juli 2026 over het toepassingsgebied van artikel 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
|
||||
{Title: "Vragen en antwoorden over artikel 50", Description: "Praktische uitleg over toepassingsgebied, uitzonderingen, menselijke beoordeling en redactionele controle.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
|
||||
{Title: "Gedragscode voor door AI gegenereerde inhoud", Description: "Vrijwillig instrument ter ondersteuning van markerings- en etiketteringsverplichtingen.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
|
||||
{Title: "EU-pictogrammen voor door AI gegenereerde inhoud", Description: "Optionele symbolen van de Europese Unie; gebruik alleen bewijst geen juridische naleving.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
|
||||
},
|
||||
"pt": {
|
||||
{Title: "Regulamento (UE) 2024/1689 – Regulamento da IA", Description: "Texto jurídico vinculativo, em especial o artigo 50 sobre obrigações de transparência para determinados sistemas de IA.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
|
||||
{Title: "Orientações sobre obrigações de transparência", Description: "Orientações da Comissão Europeia publicadas em 20 de julho de 2026 sobre o âmbito do artigo 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
|
||||
{Title: "Orientações sobre obrigações de transparência", Description: "Orientações da Comissão Europeia publicadas em 20 de julho de 2026 sobre o âmbito do artigo 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
|
||||
{Title: "Perguntas e respostas sobre o artigo 50", Description: "Explicações práticas sobre âmbito, exceções, revisão humana e controlo editorial.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
|
||||
{Title: "Código de boas práticas sobre conteúdos gerados por IA", Description: "Instrumento voluntário de apoio à execução das obrigações de marcação e rotulagem.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
|
||||
{Title: "Ícones da UE para conteúdos gerados por IA", Description: "Símbolos opcionais da União Europeia; a sua utilização, por si só, não comprova conformidade jurídica.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
|
||||
},
|
||||
"pl": {
|
||||
{Title: "Rozporządzenie (UE) 2024/1689 – akt w sprawie AI", Description: "Wiążący tekst prawny, w szczególności art. 50 dotyczący obowiązków przejrzystości dla niektórych systemów AI.", URL: "https://eur-lex.europa.eu/eli/reg/2024/1689/oj"},
|
||||
{Title: "Wytyczne dotyczące obowiązków przejrzystości", Description: "Wytyczne Komisji Europejskiej opublikowane 20 lipca 2026 r., wyjaśniające zakres art. 50.", URL: "https://digital-strategy.ec.europa.eu/en/library/guidelines-transparency-obligations-providers-and-deployers-ai-systems"},
|
||||
{Title: "Wytyczne dotyczące obowiązków przejrzystości", Description: "Wytyczne Komisji Europejskiej opublikowane 20 lipca 2026 r., wyjaśniające zakres art. 50.", URL: "https://digital-strategy.ec.europa.eu/en/policies/guidelines-transparency-ai-generated-content"},
|
||||
{Title: "Pytania i odpowiedzi dotyczące art. 50", Description: "Praktyczne wyjaśnienia zakresu, wyjątków, przeglądu przez człowieka i kontroli redakcyjnej.", URL: "https://digital-strategy.ec.europa.eu/en/faqs/transparency-obligations-under-article-50-ai-act"},
|
||||
{Title: "Kodeks postępowania dotyczący treści generowanych przez AI", Description: "Dobrowolne narzędzie wspierające realizację obowiązków znakowania i oznaczania.", URL: "https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content"},
|
||||
{Title: "Ikony UE dla treści generowanych przez AI", Description: "Opcjonalne symbole Unii Europejskiej; samo ich użycie nie potwierdza zgodności prawnej.", URL: "https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content"},
|
||||
@@ -176,14 +317,13 @@ func german() Page {
|
||||
Eyebrow: "Rechtlicher und redaktioneller Hintergrund",
|
||||
Title: "Warum KI-Nutzung gekennzeichnet wird",
|
||||
Lead: "Der EU AI Act führt ab August 2026 konkrete Transparenzpflichten für bestimmte KI-Systeme und Inhalte ein. Dieses Projekt unterstützt darüber hinaus eine freiwillige, verständliche und maschinenlesbare Dokumentation der KI-Nutzung in Artikeln, Webseiten und Medien.",
|
||||
StatusKicker: "Rechtsstand · 21. Juli 2026", StatusTitle: "Artikel 50 gilt ab 2. August 2026", StatusText: "Die Transparenzpflichten betreffen nicht pauschal jede Nutzung von KI. Entscheidend sind unter anderem die Rolle als Anbieter oder Betreiber, die Art des Systems, der Inhalt, der Veröffentlichungszweck und die vorhandene menschliche Prüfung.",
|
||||
StatusKicker: "Aktueller Stand · 20. Juli 2026", StatusTitle: "Artikel 50 gilt ab 2. August 2026", StatusText: "Die Transparenzpflichten betreffen nicht pauschal jede Nutzung von KI. Entscheidend sind unter anderem die Rolle als Anbieter oder Betreiber, die Art des Systems, der Inhalt, der Veröffentlichungszweck und die vorhandene menschliche Prüfung.",
|
||||
ContentsTitle: "Auf dieser Seite", Contents: []string{"Einordnung des EU AI Act", "Pflichten aus Artikel 50", "Bedeutung für Artikel und Webseiten", "Anforderungen an die Kennzeichnung", "Rolle dieses Standards", "Offizielle Quellen"},
|
||||
OverviewTitle: "Einordnung: keine allgemeine Kennzeichnungspflicht für jede KI-Hilfe",
|
||||
OverviewParagraphs: []string{
|
||||
"Der AI Act ist die Verordnung (EU) 2024/1689. Er verfolgt einen risikobasierten Ansatz und enthält neben Regeln für verbotene oder hochriskante Systeme auch besondere Transparenzpflichten. Für die Kennzeichnung von KI-generierten oder manipulierten Inhalten ist vor allem Artikel 50 relevant.",
|
||||
"Artikel 50 unterscheidet zwischen Anbietern von KI-Systemen und Personen oder Organisationen, die solche Systeme einsetzen. Ein Anbieter muss bestimmte Systeme technisch so gestalten, dass Interaktionen und synthetische Ausgaben erkennbar werden. Betreiber oder veröffentlichende Stellen müssen in bestimmten Fällen Menschen sichtbar informieren, etwa bei Deepfakes oder bestimmten Texten zu Angelegenheiten von öffentlichem Interesse.",
|
||||
"Nicht jede Recherchehilfe, Rechtschreibkorrektur, Übersetzung oder redaktionelle Unterstützung löst automatisch dieselbe Pflicht aus. Die konkrete Bewertung hängt vom Anwendungsfall ab. Eine freiwillige Offenlegung kann dennoch sinnvoll sein, weil sie Erwartungen klärt, redaktionelle Verantwortung sichtbar macht und über das gesetzliche Mindestmaß hinaus Vertrauen schafft.",
|
||||
"Dieses Projekt bildet nicht den gesamten AI Act ab. Andere Pflichten wie AI Literacy nach Artikel 4, Verbote nach Artikel 5, Hochrisiko-Anforderungen oder Pflichten für General-Purpose-AI-Modelle müssen unabhängig davon geprüft und erfüllt werden.",
|
||||
},
|
||||
ScopeTitle: "Was Artikel 50 konkret regelt", ScopeLead: "Die Vorschrift enthält mehrere eigenständige Transparenzpflichten. Für Webseiten und redaktionelle Inhalte sind insbesondere die technische Markierung durch Anbieter sowie die sichtbare Offenlegung durch Betreiber relevant.",
|
||||
Obligations: []Card{
|
||||
@@ -208,9 +348,8 @@ func german() Page {
|
||||
},
|
||||
ProjectTitle: "Wie dieser Standard einzuordnen ist",
|
||||
ProjectParagraphs: []string{
|
||||
"AI Usage Disclosure ist ein technisches und redaktionelles Werkzeug. Es ermöglicht, die Nutzung von KI nach Inhaltsbestandteilen zu dokumentieren: beispielsweise Text ohne KI, Titelbild KI-generiert, Recherche KI-unterstützt und – nur wenn tatsächlich erfolgt – eine menschliche Prüfung. Eine einzelne URL bündelt die sichtbare Erklärung, die tabellarischen Details und ein maschinenlesbares JSON-LD-Manifest.",
|
||||
"AI Usage Disclosure ist ein technisches und redaktionelles Werkzeug. Es ermöglicht, die Nutzung von KI nach Inhaltsbestandteilen zu dokumentieren: beispielsweise Text ohne KI, Titelbild KI-generiert, Recherche KI-unterstützt und fachliche Prüfung durch Menschen. Eine einzelne URL bündelt die sichtbare Erklärung, die tabellarischen Details und ein maschinenlesbares JSON-LD-Manifest.",
|
||||
"Der Standard ist bewusst weiter gefasst als die gesetzlichen Mindestfälle. Organisationen können damit auch freiwillige Transparenz veröffentlichen, etwa für Rechercheunterstützung oder generierte Illustrationen, die kein Deepfake darstellen. Gleichzeitig entscheidet das Programm nicht, ob im konkreten Einzelfall eine gesetzliche Pflicht besteht.",
|
||||
"Das JSON-LD ist ergänzende Dokumentation. Es ersetzt nicht automatisch die wirksame, interoperable, robuste und zuverlässige technische Markierung, die Anbieter generativer KI-Systeme in den von Artikel 50 Absatz 2 erfassten Fällen gewährleisten müssen.",
|
||||
},
|
||||
ProjectItems: []string{
|
||||
"Komponentenbezogene Angaben für Text, Bilder, Recherche, Übersetzung, Audio, Video und Code",
|
||||
@@ -231,14 +370,13 @@ func english() Page {
|
||||
MetaDescription: "Background on AI labelling, Article 50 of the EU AI Act and voluntary documentation of AI use in digital content.",
|
||||
NavProduct: "Features", NavGenerator: "Generator", NavBackground: "Background", LanguageLabel: "Language",
|
||||
Eyebrow: "Legal and editorial background", Title: "Why AI use is disclosed", Lead: "From August 2026, the EU AI Act introduces specific transparency obligations for certain AI systems and content. This project additionally supports voluntary, understandable and machine-readable documentation of AI use in articles, websites and media.",
|
||||
StatusKicker: "Legal status · 21 July 2026", StatusTitle: "Article 50 applies from 2 August 2026", StatusText: "The transparency obligations do not apply indiscriminately to every use of AI. Relevant factors include the role of provider or deployer, the type of system, the content, the purpose of publication and the human review actually performed.",
|
||||
StatusKicker: "Current as of 20 July 2026", StatusTitle: "Article 50 applies from 2 August 2026", StatusText: "The transparency obligations do not apply indiscriminately to every use of AI. Relevant factors include the role of provider or deployer, the type of system, the content, the purpose of publication and the human review actually performed.",
|
||||
ContentsTitle: "On this page", Contents: []string{"Context of the EU AI Act", "Obligations under Article 50", "Meaning for articles and websites", "Requirements for disclosure", "Role of this standard", "Official sources"},
|
||||
OverviewTitle: "Context: there is no blanket labelling duty for every AI-assisted task",
|
||||
OverviewParagraphs: []string{
|
||||
"The AI Act is Regulation (EU) 2024/1689. It follows a risk-based approach and, alongside rules for prohibited and high-risk systems, contains specific transparency obligations. Article 50 is particularly relevant to the identification of AI-generated or manipulated content.",
|
||||
"Article 50 distinguishes between providers of AI systems and persons or organisations deploying them. Providers must design certain systems so that interactions and synthetic outputs can be identified. Deployers and publishers must visibly inform people in particular situations, including deepfakes and certain text concerning matters of public interest.",
|
||||
"Not every research aid, spelling correction, translation or editorial assistance automatically triggers the same duty. The assessment depends on the circumstances. Voluntary disclosure may nevertheless be valuable because it sets expectations, makes editorial responsibility visible and can build trust beyond the legal minimum.",
|
||||
"This project does not cover the entire AI Act. Other obligations, including AI literacy under Article 4, prohibited practices under Article 5, high-risk requirements and obligations for general-purpose AI models, must be assessed and met independently where applicable.",
|
||||
},
|
||||
ScopeTitle: "What Article 50 covers", ScopeLead: "The provision contains several distinct transparency obligations. For websites and editorial content, provider-side technical marking and deployer-side visible disclosure are especially relevant.",
|
||||
Obligations: []Card{
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestGermanPageDistinguishesLegalAndVoluntaryDisclosure(t *testing.T) {
|
||||
|
||||
func TestUnknownLanguageFallsBackToEnglish(t *testing.T) {
|
||||
page := Build("xx")
|
||||
if page.Title != "Why AI use is disclosed" {
|
||||
if page.Title != "Content needs context." {
|
||||
t.Fatalf("unexpected fallback title %q", page.Title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ type Options struct {
|
||||
Label string
|
||||
Message string
|
||||
Extent string
|
||||
Preset string
|
||||
Article bool
|
||||
Style string
|
||||
Theme string
|
||||
Link string
|
||||
@@ -26,23 +24,15 @@ var extentColors = map[string]string{
|
||||
"none": "#2f855a", "assisted": "#2b6cb0", "partial": "#b7791f", "mostly": "#c05621", "full": "#c53030",
|
||||
}
|
||||
|
||||
var emojiPresetColors = map[string]string{
|
||||
"no-ai": "#2f855a", "research": "#2b6cb0", "summary": "#b7791f", "full": "#c53030",
|
||||
}
|
||||
|
||||
func Render(o Options) ([]byte, string) {
|
||||
label := truncate(strings.TrimSpace(o.Label), 40)
|
||||
if label == "" {
|
||||
label = "AI use"
|
||||
label = "UCNG"
|
||||
}
|
||||
message := truncate(strings.TrimSpace(o.Message), 80)
|
||||
if message == "" {
|
||||
message = o.Extent
|
||||
}
|
||||
if o.Theme == "emoji" {
|
||||
return renderEmoji(o, label, message)
|
||||
}
|
||||
|
||||
style := o.Style
|
||||
if style != "flat-square" {
|
||||
style = "flat"
|
||||
@@ -73,100 +63,17 @@ func Render(o Options) ([]byte, string) {
|
||||
title := html.EscapeString(label + ": " + message)
|
||||
labelEsc := html.EscapeString(label)
|
||||
messageEsc := html.EscapeString(message)
|
||||
openLink, closeLink := svgLink(o.Link)
|
||||
|
||||
openLink, closeLink := "", ""
|
||||
if strings.HasPrefix(o.Link, "https://") || strings.HasPrefix(o.Link, "http://") {
|
||||
openLink = `<a href="` + html.EscapeString(o.Link) + `" target="_top">`
|
||||
closeLink = `</a>`
|
||||
}
|
||||
|
||||
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" width="%d" height="20" viewBox="0 0 %d 20"><title>%s</title>%s<linearGradient id="s" x2="0" y2="100%%"><stop offset="0" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-opacity=".12"/></linearGradient><clipPath id="r"><rect width="%d" height="20" rx="%d" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="%d" height="20" fill="%s"/><rect x="%d" width="%d" height="20" fill="%s"/><rect width="%d" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text><text x="%d" y="14">%s</text><text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text><text x="%d" y="14">%s</text></g>%s</svg>`,
|
||||
title, total, total, title, openLink, total, radius, leftWidth, leftColor, leftWidth, rightWidth, rightColor, total,
|
||||
leftWidth/2, labelEsc, leftWidth/2, labelEsc, leftWidth+rightWidth/2, messageEsc, leftWidth+rightWidth/2, messageEsc, closeLink)
|
||||
return withETag([]byte(svg))
|
||||
}
|
||||
|
||||
func renderEmoji(o Options, label, message string) ([]byte, string) {
|
||||
iconName := emojiIconName(o)
|
||||
background := emojiPresetColors[o.Preset]
|
||||
if o.Article {
|
||||
background = "#7c3aed"
|
||||
}
|
||||
if background == "" {
|
||||
background = extentColors[o.Extent]
|
||||
}
|
||||
if background == "" {
|
||||
background = "#4a5568"
|
||||
}
|
||||
foreground := "#ffffff"
|
||||
if isHexColor(o.LeftColor) {
|
||||
foreground = o.LeftColor
|
||||
}
|
||||
if isHexColor(o.RightColor) {
|
||||
background = o.RightColor
|
||||
}
|
||||
|
||||
title := html.EscapeString(label + ": " + message)
|
||||
openLink, closeLink := svgLink(o.Link)
|
||||
icon := emojiIcon(iconName, foreground)
|
||||
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="%s" focusable="false" width="64" height="64" viewBox="0 0 64 64" data-theme="emoji" data-icon="%s"><title>%s</title>%s<rect width="64" height="64" rx="14" fill="%s"/><path d="M3 18C14 5 35 1 57 5c3 .5 5 3 5 6v7C40 13 20 14 3 27Z" fill="#fff" opacity=".11"/><rect x=".75" y=".75" width="62.5" height="62.5" rx="13.25" fill="none" stroke="#000" stroke-opacity=".14" stroke-width="1.5"/>%s%s</svg>`,
|
||||
title, iconName, title, openLink, background, icon, closeLink)
|
||||
return withETag([]byte(svg))
|
||||
}
|
||||
|
||||
func emojiIconName(o Options) string {
|
||||
if o.Article {
|
||||
return "article"
|
||||
}
|
||||
switch o.Preset {
|
||||
case "no-ai":
|
||||
return "human"
|
||||
case "research":
|
||||
return "research"
|
||||
case "summary":
|
||||
return "summary"
|
||||
case "full":
|
||||
return "bolt"
|
||||
}
|
||||
switch o.Extent {
|
||||
case "none":
|
||||
return "human"
|
||||
case "partial":
|
||||
return "partial"
|
||||
case "mostly":
|
||||
return "mostly"
|
||||
case "full":
|
||||
return "bolt"
|
||||
default:
|
||||
return "assisted"
|
||||
}
|
||||
}
|
||||
|
||||
func emojiIcon(name, color string) string {
|
||||
stroke := ` fill="none" stroke="` + color + `" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"`
|
||||
switch name {
|
||||
case "human":
|
||||
return `<circle cx="32" cy="21" r="8"` + stroke + `/><path d="M17 50c1.8-10 7.5-15 15-15s13.2 5 15 15"` + stroke + `/>`
|
||||
case "research":
|
||||
return `<circle cx="27" cy="27" r="12"` + stroke + `/><path d="m36 36 13 13"` + stroke + `/><path d="M27 21v12M21 27h12"` + stroke + `/>`
|
||||
case "summary":
|
||||
return `<rect x="17" y="12" width="30" height="40" rx="5"` + stroke + `/><path d="M24 24h16M24 32h16M24 40h11"` + stroke + `/>`
|
||||
case "bolt":
|
||||
return `<path d="M36 8 18 35h12l-2 21 18-29H34Z" fill="` + color + `"/>`
|
||||
case "article":
|
||||
return `<rect x="14" y="14" width="15" height="15" rx="4"` + stroke + `/><rect x="35" y="14" width="15" height="15" rx="4"` + stroke + `/><rect x="14" y="35" width="15" height="15" rx="4"` + stroke + `/><rect x="35" y="35" width="15" height="15" rx="4"` + stroke + `/>`
|
||||
case "partial":
|
||||
return `<circle cx="32" cy="32" r="18"` + stroke + `/><path d="M32 14a18 18 0 0 0 0 36Z" fill="` + color + `"/>`
|
||||
case "mostly":
|
||||
return `<path d="M18 45V35M32 45V27M46 45V18"` + stroke + `/><path d="M14 50h36"` + stroke + `/>`
|
||||
default:
|
||||
return `<path d="M32 12c1.5 8.2 5.8 12.5 14 14-8.2 1.5-12.5 5.8-14 14-1.5-8.2-5.8-12.5-14-14 8.2-1.5 12.5-5.8 14-14Z"` + stroke + `/><path d="M46 39c.7 3.8 2.7 5.8 6.5 6.5C48.7 46.2 46.7 48.2 46 52c-.7-3.8-2.7-5.8-6.5-6.5 3.8-.7 5.8-2.7 6.5-6.5Z" fill="` + color + `"/>`
|
||||
}
|
||||
}
|
||||
|
||||
func svgLink(link string) (string, string) {
|
||||
if strings.HasPrefix(link, "https://") || strings.HasPrefix(link, "http://") {
|
||||
return `<a href="` + html.EscapeString(link) + `" target="_top">`, `</a>`
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func withETag(data []byte) ([]byte, string) {
|
||||
data := []byte(svg)
|
||||
hash := sha256.Sum256(data)
|
||||
return data, `"` + hex.EncodeToString(hash[:12]) + `"`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package badge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -18,77 +17,3 @@ func TestRenderEscapesText(t *testing.T) {
|
||||
t.Fatal("missing etag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEmojiIsSquareAndPresetSpecific(t *testing.T) {
|
||||
data, etag := Render(Options{
|
||||
Label: "KI-Nutzung",
|
||||
Message: "Vollständige Inhaltserstellung",
|
||||
Extent: "full",
|
||||
Preset: "full",
|
||||
Theme: "emoji",
|
||||
})
|
||||
body := string(data)
|
||||
for _, expected := range []string{
|
||||
`width="64" height="64"`,
|
||||
`viewBox="0 0 64 64"`,
|
||||
`data-theme="emoji"`,
|
||||
`data-icon="bolt"`,
|
||||
`fill="#c53030"`,
|
||||
`<title>KI-Nutzung: Vollständige Inhaltserstellung</title>`,
|
||||
} {
|
||||
if !bytes.Contains(data, []byte(expected)) {
|
||||
t.Fatalf("emoji SVG missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if etag == "" {
|
||||
t.Fatal("missing etag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEmojiUsesCustomColoursAndEscapesAccessibleText(t *testing.T) {
|
||||
data, _ := Render(Options{
|
||||
Label: `<AI>`,
|
||||
Message: `Research & review`,
|
||||
Extent: "assisted",
|
||||
Preset: "research",
|
||||
Theme: "emoji",
|
||||
LeftColor: "#112233",
|
||||
RightColor: "#abcdef",
|
||||
})
|
||||
body := string(data)
|
||||
for _, expected := range []string{
|
||||
`data-icon="research"`,
|
||||
`fill="#abcdef"`,
|
||||
`stroke="#112233"`,
|
||||
`<AI>: Research & review`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("emoji SVG missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `<AI>`) {
|
||||
t.Fatal("accessible text was not escaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiIconMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options Options
|
||||
icon string
|
||||
}{
|
||||
{name: "no AI", options: Options{Extent: "none", Preset: "no-ai", Theme: "emoji"}, icon: "human"},
|
||||
{name: "research", options: Options{Extent: "assisted", Preset: "research", Theme: "emoji"}, icon: "research"},
|
||||
{name: "summary", options: Options{Extent: "assisted", Preset: "summary", Theme: "emoji"}, icon: "summary"},
|
||||
{name: "full", options: Options{Extent: "full", Preset: "full", Theme: "emoji"}, icon: "bolt"},
|
||||
{name: "article", options: Options{Extent: "mostly", Article: true, Theme: "emoji"}, icon: "article"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, _ := Render(tt.options)
|
||||
if !bytes.Contains(data, []byte(`data-icon="`+tt.icon+`"`)) {
|
||||
t.Fatalf("expected icon %q: %s", tt.icon, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
|
||||
const (
|
||||
SchemaVersion = "1.2"
|
||||
PreviousSchemaVersion = "1.1"
|
||||
LegacySchemaVersion = "1.0"
|
||||
LegacySchemaVersion = "1.1"
|
||||
LegacySchemaVersion10 = "1.0"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCustomTextRequiresPro = errors.New("custom declaration text requires the Pro feature custom_text")
|
||||
ErrCustomBadgeRequiresPro = errors.New("custom badge presentation requires the Pro feature custom_badge")
|
||||
ErrCustomTextRequiresPro = errors.New("custom declaration text requires the licensed capability custom_text")
|
||||
ErrCustomBadgeRequiresPro = errors.New("custom badge presentation requires the licensed capability custom_badge")
|
||||
hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
)
|
||||
|
||||
@@ -32,14 +32,37 @@ type Component struct {
|
||||
}
|
||||
|
||||
type Responsibility struct {
|
||||
Assumed bool `json:"assumed,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type LegalContext struct {
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
// RegulatoryContext records factual context that can be relevant to Article 50
|
||||
// of Regulation (EU) 2024/1689. It deliberately does not encode a legal
|
||||
// conclusion; assessment output is advisory and derived from these facts.
|
||||
type RegulatoryContext struct {
|
||||
Framework string `json:"framework,omitempty"`
|
||||
PublicInterestText bool `json:"publicInterestText,omitempty"`
|
||||
Deepfake bool `json:"deepfake,omitempty"`
|
||||
ArtisticCreativeSatiricalFictional bool `json:"artisticCreativeSatiricalFictional,omitempty"`
|
||||
SubstantialHumanReview bool `json:"substantialHumanReview,omitempty"`
|
||||
EditorialResponsibilityConfirmed bool `json:"editorialResponsibilityConfirmed,omitempty"`
|
||||
FirstExposureDisclosure bool `json:"firstExposureDisclosure,omitempty"`
|
||||
AccessibilityConsidered bool `json:"accessibilityConsidered,omitempty"`
|
||||
}
|
||||
|
||||
// Article50Assessment is a non-binding technical decision-support result.
|
||||
// It must never be presented as legal advice or a legal determination.
|
||||
type Article50Finding struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type Article50Assessment struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Applicable bool `json:"potentiallyApplicable"`
|
||||
Findings []Article50Finding `json:"findings,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type Presentation struct {
|
||||
@@ -60,7 +83,7 @@ type Declaration struct {
|
||||
Language string `json:"language"`
|
||||
Components map[string]Component `json:"components"`
|
||||
EditorialResponsibility *Responsibility `json:"editorialResponsibility,omitempty"`
|
||||
LegalContext *LegalContext `json:"legalContext,omitempty"`
|
||||
RegulatoryContext *RegulatoryContext `json:"regulatoryContext,omitempty"`
|
||||
Assurance string `json:"assurance"`
|
||||
Presentation *Presentation `json:"presentation,omitempty"`
|
||||
}
|
||||
@@ -79,14 +102,10 @@ type ParseOptions struct {
|
||||
}
|
||||
|
||||
var Presets = map[string]Preset{
|
||||
// Human review is deliberately not presumed by presets. Under Article 50(4)
|
||||
// AI Act, substantive human review/editorial control can affect whether
|
||||
// certain public-interest text must be labelled. The factual review process
|
||||
// must therefore be selected explicitly by the publisher.
|
||||
"no-ai": {ID: "no-ai", Extent: "none", Review: "none"},
|
||||
"research": {ID: "research", Extent: "assisted", Activities: []string{"research"}, Review: "none"},
|
||||
"summary": {ID: "summary", Extent: "assisted", Activities: []string{"summarisation"}, Review: "none"},
|
||||
"full": {ID: "full", Extent: "full", Activities: []string{"generation"}, Review: "none"},
|
||||
"research": {ID: "research", Extent: "assisted", Activities: []string{"research"}, Review: "editorial"},
|
||||
"summary": {ID: "summary", Extent: "assisted", Activities: []string{"summarisation"}, Review: "editorial"},
|
||||
"full": {ID: "full", Extent: "full", Activities: []string{"generation"}, Review: "editorial"},
|
||||
}
|
||||
|
||||
var validExtents = map[string]bool{"none": true, "assisted": true, "partial": true, "mostly": true, "full": true}
|
||||
@@ -98,8 +117,6 @@ var validActivities = map[string]bool{
|
||||
"transcription": true, "classification": true,
|
||||
}
|
||||
var validComponents = map[string]bool{"text": true, "coverImage": true, "image": true, "research": true, "translation": true, "audio": true, "video": true, "code": true, "other": true}
|
||||
var validLegalCategories = map[string]bool{"deepfake": true, "publicInterestText": true, "artisticCreativeSatiricalFictional": true, "otherVoluntary": true}
|
||||
var validResponsibilityRoles = map[string]bool{"publisher": true, "other": true}
|
||||
|
||||
func NewFromQuery(values url.Values, contextURL string) (Declaration, error) {
|
||||
return NewFromQueryWithOptions(values, contextURL, ParseOptions{DefaultLanguage: "de"})
|
||||
@@ -148,8 +165,7 @@ func NewFromQueryWithOptions(values url.Values, contextURL string, options Parse
|
||||
extent = "assisted"
|
||||
}
|
||||
if review == "" {
|
||||
// Do not infer a legally relevant human-review process from AI usage alone.
|
||||
review = "none"
|
||||
review = "editorial"
|
||||
}
|
||||
assurance := clean(values.Get("assurance"), 32)
|
||||
if assurance == "" {
|
||||
@@ -165,8 +181,11 @@ func NewFromQueryWithOptions(values url.Values, contextURL string, options Parse
|
||||
}
|
||||
componentReview := clean(values.Get(name+"Review"), 24)
|
||||
if componentReview == "" {
|
||||
// Human review must be stated explicitly; never infer editorial control.
|
||||
componentReview = "none"
|
||||
if componentExtent == "none" {
|
||||
componentReview = "none"
|
||||
} else {
|
||||
componentReview = "editorial"
|
||||
}
|
||||
}
|
||||
componentActivities := splitCSV(values.Get(name + "Activities"))
|
||||
if len(componentActivities) == 0 {
|
||||
@@ -203,13 +222,22 @@ func NewFromQueryWithOptions(values url.Values, contextURL string, options Parse
|
||||
}
|
||||
responsibleName := clean(values.Get("responsible"), 200)
|
||||
responsibleURL := clean(values.Get("responsibleUrl"), 2048)
|
||||
responsibleRole := clean(values.Get("responsibleRole"), 24)
|
||||
if responsibleName != "" || responsibleURL != "" || responsibleRole != "" {
|
||||
d.EditorialResponsibility = &Responsibility{Assumed: responsibleName != "", Role: responsibleRole, Name: responsibleName, URL: responsibleURL}
|
||||
if responsibleName != "" || responsibleURL != "" {
|
||||
d.EditorialResponsibility = &Responsibility{Name: responsibleName, URL: responsibleURL}
|
||||
}
|
||||
legalCategories := splitCSV(values.Get("legalContext"))
|
||||
if len(legalCategories) > 0 {
|
||||
d.LegalContext = &LegalContext{Categories: legalCategories}
|
||||
|
||||
regulatory := &RegulatoryContext{
|
||||
Framework: "EU-AI-Act-Article-50",
|
||||
PublicInterestText: parseBool(values.Get("publicInterestText")),
|
||||
Deepfake: parseBool(values.Get("deepfake")),
|
||||
ArtisticCreativeSatiricalFictional: parseBool(values.Get("artisticCreativeSatiricalFictional")),
|
||||
SubstantialHumanReview: parseBool(values.Get("substantialHumanReview")),
|
||||
EditorialResponsibilityConfirmed: parseBool(values.Get("editorialResponsibilityConfirmed")),
|
||||
FirstExposureDisclosure: parseBool(values.Get("firstExposureDisclosure")),
|
||||
AccessibilityConsidered: parseBool(values.Get("accessibilityConsidered")),
|
||||
}
|
||||
if regulatory.PublicInterestText || regulatory.Deepfake || regulatory.ArtisticCreativeSatiricalFictional || regulatory.SubstantialHumanReview || regulatory.EditorialResponsibilityConfirmed || regulatory.FirstExposureDisclosure || regulatory.AccessibilityConsidered {
|
||||
d.RegulatoryContext = regulatory
|
||||
}
|
||||
|
||||
presentation := &Presentation{
|
||||
@@ -237,16 +265,16 @@ func Validate(d Declaration) error {
|
||||
var problems []string
|
||||
switch d.SchemaVersion {
|
||||
case SchemaVersion:
|
||||
case PreviousSchemaVersion:
|
||||
if d.LegalContext != nil {
|
||||
problems = append(problems, "legalContext requires schemaVersion 1.2")
|
||||
}
|
||||
case LegacySchemaVersion:
|
||||
if d.Presentation != nil {
|
||||
problems = append(problems, "presentation requires schemaVersion 1.1 or later")
|
||||
if d.RegulatoryContext != nil {
|
||||
problems = append(problems, "regulatoryContext requires schemaVersion 1.2")
|
||||
}
|
||||
if d.LegalContext != nil {
|
||||
problems = append(problems, "legalContext requires schemaVersion 1.2")
|
||||
case LegacySchemaVersion10:
|
||||
if d.Presentation != nil {
|
||||
problems = append(problems, "presentation requires schemaVersion 1.1 or newer")
|
||||
}
|
||||
if d.RegulatoryContext != nil {
|
||||
problems = append(problems, "regulatoryContext requires schemaVersion 1.2")
|
||||
}
|
||||
if d.Language != "de" && d.Language != "en" {
|
||||
problems = append(problems, "schemaVersion 1.0 supports only de and en")
|
||||
@@ -276,49 +304,14 @@ func Validate(d Declaration) error {
|
||||
problems = append(problems, "declaredAt must be RFC3339")
|
||||
}
|
||||
}
|
||||
if d.EditorialResponsibility != nil {
|
||||
if strings.TrimSpace(d.EditorialResponsibility.Name) == "" {
|
||||
problems = append(problems, "editorialResponsibility.name is required when editorialResponsibility is present")
|
||||
}
|
||||
if d.EditorialResponsibility.Role != "" && !validResponsibilityRoles[d.EditorialResponsibility.Role] {
|
||||
problems = append(problems, "editorialResponsibility.role must be publisher or other")
|
||||
}
|
||||
if d.EditorialResponsibility.URL != "" {
|
||||
if u, err := url.ParseRequestURI(d.EditorialResponsibility.URL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
problems = append(problems, "editorialResponsibility.url must be an absolute URL")
|
||||
}
|
||||
if d.EditorialResponsibility != nil && d.EditorialResponsibility.URL != "" {
|
||||
if u, err := url.ParseRequestURI(d.EditorialResponsibility.URL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
problems = append(problems, "editorialResponsibility.url must be an absolute URL")
|
||||
}
|
||||
}
|
||||
if d.LegalContext != nil {
|
||||
seenLegal := map[string]bool{}
|
||||
for _, category := range d.LegalContext.Categories {
|
||||
if !validLegalCategories[category] {
|
||||
problems = append(problems, "invalid legalContext category: "+category)
|
||||
}
|
||||
if seenLegal[category] {
|
||||
problems = append(problems, "duplicate legalContext category: "+category)
|
||||
}
|
||||
seenLegal[category] = true
|
||||
}
|
||||
if seenLegal["otherVoluntary"] && len(seenLegal) > 1 {
|
||||
problems = append(problems, "otherVoluntary cannot be combined with other legalContext categories")
|
||||
}
|
||||
if seenLegal["publicInterestText"] {
|
||||
text, ok := d.Components["text"]
|
||||
if !ok || text.AIExtent == "none" {
|
||||
problems = append(problems, "publicInterestText requires a text component with AI involvement")
|
||||
}
|
||||
}
|
||||
if seenLegal["deepfake"] {
|
||||
hasAIMedia := false
|
||||
for _, name := range []string{"coverImage", "image", "audio", "video"} {
|
||||
if c, ok := d.Components[name]; ok && c.AIExtent != "none" {
|
||||
hasAIMedia = true
|
||||
}
|
||||
}
|
||||
if !hasAIMedia {
|
||||
problems = append(problems, "deepfake requires an image, audio or video component with AI involvement")
|
||||
}
|
||||
if rc := d.RegulatoryContext; rc != nil {
|
||||
if rc.Framework != "" && rc.Framework != "EU-AI-Act-Article-50" {
|
||||
problems = append(problems, "regulatoryContext.framework must be EU-AI-Act-Article-50")
|
||||
}
|
||||
}
|
||||
for name, c := range d.Components {
|
||||
@@ -378,6 +371,63 @@ func (p *Presentation) empty() bool {
|
||||
return p == nil || (p.Title == "" && p.Description == "" && p.BadgeLabel == "" && p.BadgeMessage == "" && p.LeftColor == "" && p.RightColor == "")
|
||||
}
|
||||
|
||||
// AssessArticle50 derives a conservative, non-binding decision-support result
|
||||
// from the factual context recorded in a declaration.
|
||||
func AssessArticle50(d Declaration) Article50Assessment {
|
||||
rc := d.RegulatoryContext
|
||||
if rc == nil {
|
||||
return Article50Assessment{Code: "not_assessed", Severity: "neutral", Applicable: false}
|
||||
}
|
||||
result := Article50Assessment{Code: "voluntary_transparency", Severity: "neutral", Applicable: false}
|
||||
if rc.Deepfake {
|
||||
result.Findings = append(result.Findings, Article50Finding{Code: "deepfake_disclosure_relevant", Severity: "attention"})
|
||||
}
|
||||
if rc.PublicInterestText {
|
||||
if rc.SubstantialHumanReview && rc.EditorialResponsibilityConfirmed {
|
||||
result.Findings = append(result.Findings, Article50Finding{Code: "public_interest_text_possible_exemption", Severity: "caution"})
|
||||
} else {
|
||||
result.Findings = append(result.Findings, Article50Finding{Code: "public_interest_text_disclosure_relevant", Severity: "attention"})
|
||||
}
|
||||
}
|
||||
if len(result.Findings) == 1 {
|
||||
result.Code = result.Findings[0].Code
|
||||
result.Severity = result.Findings[0].Severity
|
||||
result.Applicable = true
|
||||
} else if len(result.Findings) > 1 {
|
||||
result.Code = "multiple_article50_contexts"
|
||||
result.Severity = "caution"
|
||||
result.Applicable = true
|
||||
for _, finding := range result.Findings {
|
||||
if finding.Severity == "attention" {
|
||||
result.Severity = "attention"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.Applicable && !rc.FirstExposureDisclosure {
|
||||
result.Warnings = append(result.Warnings, "first_exposure_not_confirmed")
|
||||
}
|
||||
if result.Applicable && !rc.AccessibilityConsidered {
|
||||
result.Warnings = append(result.Warnings, "accessibility_not_confirmed")
|
||||
}
|
||||
if rc.PublicInterestText && rc.SubstantialHumanReview && !rc.EditorialResponsibilityConfirmed {
|
||||
result.Warnings = append(result.Warnings, "editorial_responsibility_not_confirmed")
|
||||
}
|
||||
if rc.Deepfake && rc.ArtisticCreativeSatiricalFictional {
|
||||
result.Warnings = append(result.Warnings, "artistic_context_disclosure_manner")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clean(s string, max int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) > max {
|
||||
|
||||
@@ -3,7 +3,6 @@ package declaration
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -20,9 +19,6 @@ func TestPresetQuery(t *testing.T) {
|
||||
if d.Language != "fr" {
|
||||
t.Fatalf("unexpected language: %s", d.Language)
|
||||
}
|
||||
if c.HumanReview != "none" {
|
||||
t.Fatalf("preset inferred human review=%q", c.HumanReview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoAIRejectsActivities(t *testing.T) {
|
||||
@@ -49,12 +45,18 @@ func TestCustomRequiresPro(t *testing.T) {
|
||||
|
||||
func TestLegacySchemaValidation(t *testing.T) {
|
||||
d := Declaration{Context: "https://example.org/context/v1", Type: "AIUsageDeclaration", SchemaVersion: LegacySchemaVersion, Language: "en", Assurance: "selfDeclared", Components: map[string]Component{"text": {AIExtent: "assisted", HumanReview: "editorial"}}}
|
||||
d.Presentation = &Presentation{Title: "Allowed since 1.1"}
|
||||
if err := Validate(d); err != nil {
|
||||
t.Fatalf("legacy declaration should remain valid: %v", err)
|
||||
t.Fatalf("1.1 declaration should remain valid: %v", err)
|
||||
}
|
||||
d.Presentation = &Presentation{Title: "Not allowed in 1.0"}
|
||||
d.RegulatoryContext = &RegulatoryContext{Framework: "EU-AI-Act-Article-50", Deepfake: true}
|
||||
if Validate(d) == nil {
|
||||
t.Fatal("expected presentation to require schema 1.1")
|
||||
t.Fatal("expected regulatoryContext to require schema 1.2")
|
||||
}
|
||||
d.SchemaVersion = LegacySchemaVersion10
|
||||
d.RegulatoryContext = nil
|
||||
if Validate(d) == nil {
|
||||
t.Fatal("expected presentation to require schema 1.1 or newer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +78,8 @@ func TestArticleDeclarationFromQuery(t *testing.T) {
|
||||
if d.Components["text"].HumanReview != "none" {
|
||||
t.Fatalf("no-AI component review=%q, want none", d.Components["text"].HumanReview)
|
||||
}
|
||||
if d.Components["research"].HumanReview != "none" {
|
||||
t.Fatalf("AI-assisted component review=%q, want no inferred review", d.Components["research"].HumanReview)
|
||||
if d.Components["research"].HumanReview != "editorial" {
|
||||
t.Fatalf("AI-assisted component review=%q, want editorial default", d.Components["research"].HumanReview)
|
||||
}
|
||||
if got := d.Components["research"].Activities; len(got) != 1 || got[0] != "research" {
|
||||
t.Fatalf("research activities=%v", got)
|
||||
@@ -117,48 +119,63 @@ func TestAssuranceFromQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorialResponsibilityRequiresName(t *testing.T) {
|
||||
_, err := NewFromQuery(url.Values{
|
||||
"preset": {"research"},
|
||||
"responsibleUrl": {"https://example.org/impressum"},
|
||||
}, "https://example.org/context")
|
||||
if err == nil || !strings.Contains(err.Error(), "editorialResponsibility.name is required") {
|
||||
t.Fatalf("expected editorial responsibility name validation error, got %v", err)
|
||||
func TestArticle50Assessment(t *testing.T) {
|
||||
base := Declaration{Context: "https://example.org/context/v1", Type: "AIUsageDeclaration", SchemaVersion: SchemaVersion, Language: "de", Assurance: "selfDeclared", Components: map[string]Component{"text": {AIExtent: "partial", HumanReview: "expert"}}}
|
||||
base.RegulatoryContext = &RegulatoryContext{Framework: "EU-AI-Act-Article-50", PublicInterestText: true}
|
||||
assessment := AssessArticle50(base)
|
||||
if assessment.Code != "public_interest_text_disclosure_relevant" || !assessment.Applicable {
|
||||
t.Fatalf("unexpected assessment: %#v", assessment)
|
||||
}
|
||||
base.RegulatoryContext.SubstantialHumanReview = true
|
||||
base.RegulatoryContext.EditorialResponsibilityConfirmed = true
|
||||
assessment = AssessArticle50(base)
|
||||
if assessment.Code != "public_interest_text_possible_exemption" {
|
||||
t.Fatalf("unexpected exemption assessment: %#v", assessment)
|
||||
}
|
||||
base.RegulatoryContext.Deepfake = true
|
||||
assessment = AssessArticle50(base)
|
||||
if assessment.Code != "multiple_article50_contexts" || len(assessment.Findings) != 2 {
|
||||
t.Fatalf("combined article context should preserve both findings: %#v", assessment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalContextPublicInterestRequiresAIText(t *testing.T) {
|
||||
_, err := NewFromQuery(url.Values{
|
||||
"mode": {"article"}, "lang": {"de"}, "textExtent": {"none"},
|
||||
"legalContext": {"publicInterestText"},
|
||||
}, "https://example.org/context/v1")
|
||||
if err == nil || !strings.Contains(err.Error(), "publicInterestText requires a text component with AI involvement") {
|
||||
t.Fatalf("expected public-interest consistency error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalContextDeepfakeRequiresAIMedia(t *testing.T) {
|
||||
_, err := NewFromQuery(url.Values{
|
||||
"mode": {"article"}, "lang": {"de"}, "textExtent": {"full"},
|
||||
"legalContext": {"deepfake"},
|
||||
}, "https://example.org/context/v1")
|
||||
if err == nil || !strings.Contains(err.Error(), "deepfake requires an image, audio or video component with AI involvement") {
|
||||
t.Fatalf("expected deepfake consistency error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalContextAndResponsibilityAreSerialized(t *testing.T) {
|
||||
func TestRegulatoryContextFromQuery(t *testing.T) {
|
||||
d, err := NewFromQuery(url.Values{
|
||||
"mode": {"article"}, "lang": {"de"}, "textExtent": {"partial"}, "textReview": {"expert"},
|
||||
"legalContext": {"publicInterestText"}, "responsibleRole": {"publisher"}, "responsible": {"Beispielredaktion"},
|
||||
"preset": {"research"}, "lang": {"de"}, "publicInterestText": {"true"},
|
||||
"substantialHumanReview": {"1"}, "editorialResponsibilityConfirmed": {"yes"},
|
||||
}, "https://example.org/context/v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.SchemaVersion != "1.2" || d.LegalContext == nil || len(d.LegalContext.Categories) != 1 {
|
||||
t.Fatalf("unexpected legal context: %#v", d)
|
||||
}
|
||||
if d.EditorialResponsibility == nil || !d.EditorialResponsibility.Assumed || d.EditorialResponsibility.Role != "publisher" {
|
||||
t.Fatalf("responsibility not explicitly assumed: %#v", d.EditorialResponsibility)
|
||||
if d.RegulatoryContext == nil || !d.RegulatoryContext.PublicInterestText || !d.RegulatoryContext.SubstantialHumanReview || !d.RegulatoryContext.EditorialResponsibilityConfirmed {
|
||||
t.Fatalf("unexpected regulatory context: %#v", d.RegulatoryContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoAIPresetDoesNotClaimHumanAIReview(t *testing.T) {
|
||||
values := url.Values{"preset": {"no-ai"}, "lang": {"de"}}
|
||||
d, err := NewFromQuery(values, "https://example.org/context/v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := d.Components["text"].HumanReview; got != "none" {
|
||||
t.Fatalf("no-ai review = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticle50ArtisticDeepfakeWarning(t *testing.T) {
|
||||
d := Declaration{
|
||||
RegulatoryContext: &RegulatoryContext{Deepfake: true, ArtisticCreativeSatiricalFictional: true},
|
||||
}
|
||||
assessment := AssessArticle50(d)
|
||||
found := false
|
||||
for _, warning := range assessment.Warnings {
|
||||
if warning == "artistic_context_disclosure_manner" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected artistic context warning, got %#v", assessment.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
58
internal/i18n/bulk.go
Normal file
58
internal/i18n/bulk.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package i18n
|
||||
|
||||
func applyBulkTranslations() {
|
||||
texts := map[string]map[string]string{
|
||||
"de": {
|
||||
"bulk_meta_description": "Professionelle Stapelverarbeitung für KI-Nutzungserklärungen.", "bulk_nav_workspace": "Arbeitsbereich", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Ergebnisse",
|
||||
"bulk_eyebrow": "Bulk Workspace", "bulk_title": "Viele Inhalte. Eine konsistente Erklärung.", "bulk_intro": "Erzeuge strukturierte KI-Nutzungserklärungen für ganze Artikelbestände. Ein gemeinsames Profil kann auf viele URLs angewendet oder als individuelles JSON verarbeitet werden.",
|
||||
"bulk_status": "Betriebsstatus", "bulk_license": "Bulk-Lizenz", "bulk_active": "Aktiv", "bulk_inactive": "Nicht verfügbar", "bulk_limit": "Max. Elemente", "bulk_auth": "API-Schutz", "bulk_api_key": "API-Key erforderlich", "bulk_no_api_key": "Nicht erforderlich",
|
||||
"bulk_inactive_title": "Bulk-Verarbeitung ist nicht freigeschaltet.", "bulk_inactive_help": "Die Oberfläche bleibt zur Konfiguration sichtbar; die Verarbeitung benötigt die lizenzierte Capability bulk_api.",
|
||||
"bulk_workspace_eyebrow": "Massenverarbeitung", "bulk_workspace_title": "Ein Profil auf viele Inhalte anwenden", "bulk_workspace_intro": "Füge Artikel oder Seiten zeilenweise ein und definiere einmal die gemeinsame KI-Nutzung. Abweichende Datensätze können anschließend über den erweiterten JSON-Modus verarbeitet werden.",
|
||||
"bulk_api_key_label": "Bulk API-Key", "bulk_api_key_placeholder": "API-Key für diese Instanz", "bulk_api_key_help": "Der Schlüssel wird nur im Sitzungsspeicher dieses Browser-Tabs gehalten und nicht in die erzeugten Ergebnisse geschrieben.",
|
||||
"bulk_items_label": "Artikel / Seiten", "bulk_items_placeholder": "article-1001 | https://example.org/artikel/1001\narticle-1002 | https://example.org/artikel/1002", "bulk_items_help": "Eine Zeile pro Inhalt. Unterstützt „ID | URL“, „ID<Tab>URL“ oder nur eine URL. Die ID wird andernfalls automatisch erzeugt.", "bulk_sample": "Beispieldaten einsetzen",
|
||||
"bulk_profile_title": "Gemeinsames Deklarationsprofil", "bulk_profile_intro": "Diese Angaben werden auf alle Einträge der Liste angewendet.", "bulk_cover_image": "Titelbild", "bulk_images": "Weitere Bilder", "bulk_research": "Recherche", "bulk_translation": "Übersetzung",
|
||||
"bulk_article50_title": "Regulatorischer Kontext / Artikel 50", "bulk_run": "Stapelverarbeitung starten",
|
||||
"bulk_advanced_eyebrow": "Erweitert", "bulk_advanced_title": "Individuelle JSON-Stapel", "bulk_advanced_intro": "Für heterogene Inhalte kann jeder Datensatz seine eigenen Parameter erhalten. Das Format entspricht direkt POST /v1/bulk/declarations.", "bulk_json_label": "Bulk-Request (JSON)", "bulk_json_sample": "Aktuelles Profil als JSON übernehmen", "bulk_json_run": "JSON verarbeiten",
|
||||
"bulk_results_eyebrow": "Ausgabe", "bulk_results_title": "Verarbeitungsergebnisse", "bulk_copy_json": "JSON kopieren", "bulk_download_json": "JSON herunterladen", "bulk_download_csv": "CSV herunterladen", "bulk_col_id": "ID", "bulk_col_status": "Status", "bulk_col_assessment": "Artikel-50-Einordnung", "bulk_col_links": "Ausgaben", "bulk_raw_result": "Rohdaten anzeigen", "bulk_footer_note": "Bulk-Verarbeitung · keine Rechtsberatung",
|
||||
"bulk_error_no_items": "Füge mindestens einen Inhalt hinzu.", "bulk_error_too_many_items": "Die zulässige Anzahl von Elementen wurde überschritten.", "bulk_error_api_key": "Für die Bulk-Verarbeitung ist ein API-Key erforderlich.", "bulk_status_running": "Verarbeitung läuft …", "bulk_status_done": "Verarbeitung abgeschlossen.", "bulk_status_error": "Fehler", "bulk_summary_processed": "Verarbeitet", "bulk_summary_successful": "Erfolgreich", "bulk_summary_failed": "Fehlgeschlagen", "bulk_result_ok": "OK", "bulk_result_error": "Fehler", "bulk_link_declaration": "Erklärung", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge",
|
||||
},
|
||||
"en": {
|
||||
"bulk_meta_description": "Professional batch processing for AI usage declarations.", "bulk_nav_workspace": "Workspace", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Results",
|
||||
"bulk_eyebrow": "Bulk Workspace", "bulk_title": "Many items. One consistent disclosure.", "bulk_intro": "Generate structured AI usage declarations for entire content inventories. Apply one shared profile to many URLs or process individual records as JSON.",
|
||||
"bulk_status": "Service status", "bulk_license": "Bulk licence", "bulk_active": "Active", "bulk_inactive": "Unavailable", "bulk_limit": "Max. items", "bulk_auth": "API protection", "bulk_api_key": "API key required", "bulk_no_api_key": "Not required",
|
||||
"bulk_inactive_title": "Bulk processing is not enabled.", "bulk_inactive_help": "The workspace remains visible for configuration; processing requires the licensed bulk_api capability.",
|
||||
"bulk_workspace_eyebrow": "Batch processing", "bulk_workspace_title": "Apply one profile to many items", "bulk_workspace_intro": "Paste articles or pages line by line and define their shared AI usage once. Records that differ can be processed through the advanced JSON mode.",
|
||||
"bulk_api_key_label": "Bulk API key", "bulk_api_key_placeholder": "API key for this instance", "bulk_api_key_help": "The key is kept only in this browser tab's session storage and is never written into generated results.",
|
||||
"bulk_items_label": "Articles / pages", "bulk_items_placeholder": "article-1001 | https://example.org/articles/1001\narticle-1002 | https://example.org/articles/1002", "bulk_items_help": "One item per line. Supports “ID | URL”, “ID<Tab>URL” or a URL alone. An ID is generated automatically when omitted.", "bulk_sample": "Insert sample data",
|
||||
"bulk_profile_title": "Shared declaration profile", "bulk_profile_intro": "These settings are applied to every item in the list.", "bulk_cover_image": "Cover image", "bulk_images": "Other images", "bulk_research": "Research", "bulk_translation": "Translation",
|
||||
"bulk_article50_title": "Regulatory context / Article 50", "bulk_run": "Start batch processing",
|
||||
"bulk_advanced_eyebrow": "Advanced", "bulk_advanced_title": "Individual JSON batches", "bulk_advanced_intro": "For heterogeneous content, every item can carry its own parameters. The format maps directly to POST /v1/bulk/declarations.", "bulk_json_label": "Bulk request (JSON)", "bulk_json_sample": "Use current profile as JSON", "bulk_json_run": "Process JSON",
|
||||
"bulk_results_eyebrow": "Output", "bulk_results_title": "Processing results", "bulk_copy_json": "Copy JSON", "bulk_download_json": "Download JSON", "bulk_download_csv": "Download CSV", "bulk_col_id": "ID", "bulk_col_status": "Status", "bulk_col_assessment": "Article 50 assessment", "bulk_col_links": "Outputs", "bulk_raw_result": "Show raw data", "bulk_footer_note": "Bulk processing · not legal advice",
|
||||
"bulk_error_no_items": "Add at least one item.", "bulk_error_too_many_items": "The permitted item count has been exceeded.", "bulk_error_api_key": "A bulk API key is required.", "bulk_status_running": "Processing …", "bulk_status_done": "Processing completed.", "bulk_status_error": "Error", "bulk_summary_processed": "Processed", "bulk_summary_successful": "Successful", "bulk_summary_failed": "Failed", "bulk_result_ok": "OK", "bulk_result_error": "Error", "bulk_link_declaration": "Declaration", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge",
|
||||
},
|
||||
"fr": {
|
||||
"bulk_title": "Plusieurs contenus. Une déclaration cohérente.", "bulk_intro": "Générez des déclarations structurées d’utilisation de l’IA pour des ensembles complets de contenus.", "bulk_nav_workspace": "Espace de travail", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Résultats", "bulk_eyebrow": "Traitement en lot", "bulk_workspace_eyebrow": "Traitement en lot", "bulk_workspace_title": "Appliquer un profil à plusieurs contenus", "bulk_workspace_intro": "Ajoutez les articles ou pages ligne par ligne puis définissez une seule fois leur utilisation commune de l’IA.", "bulk_items_label": "Articles / pages", "bulk_items_help": "Un contenu par ligne : « ID | URL », « ID<Tab>URL » ou URL seule.", "bulk_sample": "Insérer un exemple", "bulk_profile_title": "Profil de déclaration commun", "bulk_profile_intro": "Ces paramètres s’appliquent à tous les éléments.", "bulk_run": "Lancer le traitement", "bulk_advanced_title": "Lots JSON individuels", "bulk_advanced_intro": "Chaque élément peut utiliser ses propres paramètres.", "bulk_json_label": "Requête bulk (JSON)", "bulk_json_sample": "Reprendre le profil en JSON", "bulk_json_run": "Traiter le JSON", "bulk_results_title": "Résultats du traitement", "bulk_copy_json": "Copier le JSON", "bulk_download_json": "Télécharger JSON", "bulk_download_csv": "Télécharger CSV", "bulk_col_id": "ID", "bulk_col_status": "Statut", "bulk_col_assessment": "Évaluation article 50", "bulk_col_links": "Sorties", "bulk_raw_result": "Afficher les données brutes", "bulk_api_key_label": "Clé API bulk", "bulk_api_key_help": "La clé reste uniquement dans le stockage de session de cet onglet.", "bulk_license": "Licence bulk", "bulk_active": "Active", "bulk_inactive": "Indisponible", "bulk_limit": "Éléments max.", "bulk_auth": "Protection API", "bulk_api_key": "Clé requise", "bulk_no_api_key": "Non requise", "bulk_cover_image": "Image de couverture", "bulk_images": "Autres images", "bulk_research": "Recherche", "bulk_translation": "Traduction", "bulk_article50_title": "Contexte réglementaire / article 50", "bulk_result_ok": "OK", "bulk_result_error": "Erreur", "bulk_link_declaration": "Déclaration", "bulk_link_manifest": "Manifeste", "bulk_link_badge": "Badge",
|
||||
},
|
||||
"es": {"bulk_title": "Muchos contenidos. Una declaración coherente.", "bulk_intro": "Genera declaraciones estructuradas de uso de IA para inventarios completos de contenido.", "bulk_nav_workspace": "Espacio de trabajo", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultados", "bulk_workspace_title": "Aplicar un perfil a muchos contenidos", "bulk_items_label": "Artículos / páginas", "bulk_items_help": "Un contenido por línea: «ID | URL», «ID<Tab>URL» o solo URL.", "bulk_sample": "Insertar ejemplo", "bulk_profile_title": "Perfil común de declaración", "bulk_run": "Iniciar procesamiento", "bulk_advanced_title": "Lotes JSON individuales", "bulk_json_run": "Procesar JSON", "bulk_results_title": "Resultados", "bulk_copy_json": "Copiar JSON", "bulk_download_json": "Descargar JSON", "bulk_download_csv": "Descargar CSV", "bulk_api_key_label": "Clave API bulk", "bulk_license": "Licencia bulk", "bulk_active": "Activa", "bulk_inactive": "No disponible", "bulk_limit": "Máx. elementos", "bulk_auth": "Protección API", "bulk_api_key": "Clave requerida", "bulk_no_api_key": "No requerida", "bulk_cover_image": "Imagen de portada", "bulk_images": "Otras imágenes", "bulk_research": "Investigación", "bulk_translation": "Traducción", "bulk_article50_title": "Contexto regulatorio / artículo 50", "bulk_result_ok": "OK", "bulk_result_error": "Error", "bulk_link_declaration": "Declaración", "bulk_link_manifest": "Manifiesto", "bulk_link_badge": "Badge"},
|
||||
"it": {"bulk_title": "Molti contenuti. Una dichiarazione coerente.", "bulk_intro": "Genera dichiarazioni strutturate sull’uso dell’IA per interi archivi di contenuti.", "bulk_nav_workspace": "Area di lavoro", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Risultati", "bulk_workspace_title": "Applicare un profilo a molti contenuti", "bulk_items_label": "Articoli / pagine", "bulk_items_help": "Un contenuto per riga: «ID | URL», «ID<Tab>URL» o solo URL.", "bulk_sample": "Inserisci esempio", "bulk_profile_title": "Profilo comune", "bulk_run": "Avvia elaborazione", "bulk_advanced_title": "Batch JSON individuali", "bulk_json_run": "Elabora JSON", "bulk_results_title": "Risultati", "bulk_copy_json": "Copia JSON", "bulk_download_json": "Scarica JSON", "bulk_download_csv": "Scarica CSV", "bulk_api_key_label": "API key bulk", "bulk_license": "Licenza bulk", "bulk_active": "Attiva", "bulk_inactive": "Non disponibile", "bulk_limit": "Elementi max.", "bulk_auth": "Protezione API", "bulk_api_key": "Chiave richiesta", "bulk_no_api_key": "Non richiesta", "bulk_cover_image": "Immagine di copertina", "bulk_images": "Altre immagini", "bulk_research": "Ricerca", "bulk_translation": "Traduzione", "bulk_article50_title": "Contesto normativo / articolo 50", "bulk_result_ok": "OK", "bulk_result_error": "Errore", "bulk_link_declaration": "Dichiarazione", "bulk_link_manifest": "Manifesto", "bulk_link_badge": "Badge"},
|
||||
"nl": {"bulk_title": "Veel inhoud. Eén consistente verklaring.", "bulk_intro": "Genereer gestructureerde AI-gebruiksverklaringen voor complete contentcollecties.", "bulk_nav_workspace": "Werkruimte", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultaten", "bulk_workspace_title": "Eén profiel op veel inhoud toepassen", "bulk_items_label": "Artikelen / pagina's", "bulk_items_help": "Eén item per regel: ‘ID | URL’, ‘ID<Tab>URL’ of alleen een URL.", "bulk_sample": "Voorbeeld invoegen", "bulk_profile_title": "Gedeeld declaratieprofiel", "bulk_run": "Batchverwerking starten", "bulk_advanced_title": "Individuele JSON-batches", "bulk_json_run": "JSON verwerken", "bulk_results_title": "Resultaten", "bulk_copy_json": "JSON kopiëren", "bulk_download_json": "JSON downloaden", "bulk_download_csv": "CSV downloaden", "bulk_api_key_label": "Bulk API-key", "bulk_license": "Bulk-licentie", "bulk_active": "Actief", "bulk_inactive": "Niet beschikbaar", "bulk_limit": "Max. items", "bulk_auth": "API-beveiliging", "bulk_api_key": "Key vereist", "bulk_no_api_key": "Niet vereist", "bulk_cover_image": "Omslagafbeelding", "bulk_images": "Andere afbeeldingen", "bulk_research": "Onderzoek", "bulk_translation": "Vertaling", "bulk_article50_title": "Regelgevingscontext / artikel 50", "bulk_result_ok": "OK", "bulk_result_error": "Fout", "bulk_link_declaration": "Verklaring", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge"},
|
||||
"pt": {"bulk_title": "Muitos conteúdos. Uma declaração consistente.", "bulk_intro": "Gere declarações estruturadas de uso de IA para conjuntos completos de conteúdos.", "bulk_nav_workspace": "Área de trabalho", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Resultados", "bulk_workspace_title": "Aplicar um perfil a muitos conteúdos", "bulk_items_label": "Artigos / páginas", "bulk_items_help": "Um conteúdo por linha: «ID | URL», «ID<Tab>URL» ou apenas URL.", "bulk_sample": "Inserir exemplo", "bulk_profile_title": "Perfil de declaração comum", "bulk_run": "Iniciar processamento", "bulk_advanced_title": "Lotes JSON individuais", "bulk_json_run": "Processar JSON", "bulk_results_title": "Resultados", "bulk_copy_json": "Copiar JSON", "bulk_download_json": "Transferir JSON", "bulk_download_csv": "Transferir CSV", "bulk_api_key_label": "Chave API bulk", "bulk_license": "Licença bulk", "bulk_active": "Ativa", "bulk_inactive": "Indisponível", "bulk_limit": "Máx. elementos", "bulk_auth": "Proteção API", "bulk_api_key": "Chave necessária", "bulk_no_api_key": "Não necessária", "bulk_cover_image": "Imagem de capa", "bulk_images": "Outras imagens", "bulk_research": "Pesquisa", "bulk_translation": "Tradução", "bulk_article50_title": "Contexto regulamentar / artigo 50", "bulk_result_ok": "OK", "bulk_result_error": "Erro", "bulk_link_declaration": "Declaração", "bulk_link_manifest": "Manifesto", "bulk_link_badge": "Badge"},
|
||||
"pl": {"bulk_title": "Wiele treści. Jedna spójna deklaracja.", "bulk_intro": "Twórz ustrukturyzowane deklaracje użycia AI dla całych zbiorów treści.", "bulk_nav_workspace": "Obszar roboczy", "bulk_nav_advanced": "JSON", "bulk_nav_results": "Wyniki", "bulk_workspace_title": "Zastosuj jeden profil do wielu treści", "bulk_items_label": "Artykuły / strony", "bulk_items_help": "Jeden element na linię: „ID | URL”, „ID<Tab>URL” lub sam URL.", "bulk_sample": "Wstaw przykład", "bulk_profile_title": "Wspólny profil deklaracji", "bulk_run": "Uruchom przetwarzanie", "bulk_advanced_title": "Indywidualne partie JSON", "bulk_json_run": "Przetwórz JSON", "bulk_results_title": "Wyniki", "bulk_copy_json": "Kopiuj JSON", "bulk_download_json": "Pobierz JSON", "bulk_download_csv": "Pobierz CSV", "bulk_api_key_label": "Klucz API bulk", "bulk_license": "Licencja bulk", "bulk_active": "Aktywna", "bulk_inactive": "Niedostępna", "bulk_limit": "Maks. elementów", "bulk_auth": "Ochrona API", "bulk_api_key": "Klucz wymagany", "bulk_no_api_key": "Niewymagany", "bulk_cover_image": "Obraz okładkowy", "bulk_images": "Inne obrazy", "bulk_research": "Badania", "bulk_translation": "Tłumaczenie", "bulk_article50_title": "Kontekst regulacyjny / artykuł 50", "bulk_result_ok": "OK", "bulk_result_error": "Błąd", "bulk_link_declaration": "Deklaracja", "bulk_link_manifest": "Manifest", "bulk_link_badge": "Badge"},
|
||||
}
|
||||
|
||||
base := texts["en"]
|
||||
for code := range catalogs {
|
||||
l := catalogs[code]
|
||||
for key, value := range base {
|
||||
if l.Text[key] == "" {
|
||||
l.Text[key] = value
|
||||
}
|
||||
}
|
||||
if overrides, ok := texts[code]; ok {
|
||||
for key, value := range overrides {
|
||||
l.Text[key] = value
|
||||
}
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
}
|
||||
@@ -36,37 +36,30 @@ var catalogs = map[string]Locale{
|
||||
"generator_eyebrow": "Generator", "generator_title": "Einbettung erzeugen", "generator_intro": "Wähle eine Vorlage oder erfasse die KI-Nutzung strukturiert nach Inhaltsbestandteilen. Vorschau, Einbettungscode und maschinenlesbare Erklärung werden unmittelbar erzeugt.",
|
||||
"field_preset": "Preset", "option_custom": "Benutzerdefinierte Struktur", "field_component": "Komponente", "field_extent": "KI-Anteil", "field_review": "Menschliche Prüfung",
|
||||
"field_activities": "Tätigkeiten", "activities_help": "Kommagetrennte Standardwerte, zum Beispiel research, summarisation oder translation.",
|
||||
"review_help": "Nur den tatsächlich durchgeführten Prüfprozess angeben. Eine formale Prüfung (z. B. Rechtschreibung) ist keine substanzielle menschliche Prüfung oder redaktionelle Kontrolle im Sinne von Art. 50 Abs. 4 AI Act.",
|
||||
"field_subject": "URL des gekennzeichneten Inhalts", "legal_context_legend": "Rechtlicher Kontext (Selbsteinordnung)", "legal_context_help": "Diese Angaben helfen bei der Art.-50-Einordnung. Sie sind keine automatische Rechtsentscheidung. Mehrere Kategorien können zutreffen.", "legal_deepfake": "Deepfake / realitätsähnliche KI-Manipulation", "legal_deepfake_help": "Nur auswählen, wenn Bild, Audio oder Video bestehenden oder plausibel existierenden Personen, Objekten, Orten, Entitäten oder Ereignissen ähnelt und fälschlich authentisch oder wahr erscheinen kann.", "legal_public_interest": "KI-generierter oder manipulierter Text zu einer Angelegenheit von öffentlichem Interesse", "legal_public_interest_help": "Zum Beispiel Politik, öffentliche Verwaltung, Grundrechte, Sicherheit, Gesundheit, Umwelt, Verbrauchersicherheit oder relevante wirtschaftliche, wissenschaftliche oder kulturelle Entwicklungen.", "legal_creative": "Evident künstlerisch / kreativ / satirisch / fiktional", "legal_creative_help": "Bei Deepfakes in solchen Werken kann die Offenlegung in einer angemessenen Form erfolgen, die Darstellung oder Genuss des Werks nicht beeinträchtigt.", "legal_other_voluntary": "Andere / freiwillige Transparenz", "legal_assessment_heading": "Vorsichtige Art.-50-Einschätzung", "legal_assessment_none": "Keine besondere Art.-50-Kategorie wurde angegeben. Die Kennzeichnung wird als freiwillige Transparenz behandelt.", "legal_assessment_voluntary": "Die Angaben sprechen derzeit für eine freiwillige Transparenzkennzeichnung; daraus folgt keine Aussage, dass sonstige AI-Act-Pflichten nicht gelten.", "legal_assessment_deepfake": "Auf Grundlage der Selbsteinordnung als Deepfake spricht vieles dafür, dass eine klare und unterscheidbare Offenlegung spätestens bei der ersten Exposition erforderlich ist.", "legal_assessment_deepfake_creative": "Der Inhalt ist als Deepfake und zugleich als evident künstlerisch, kreativ, satirisch oder fiktional eingeordnet. Eine Offenlegung bleibt grundsätzlich relevant, kann aber in angemessener Weise erfolgen, die Darstellung oder Genuss des Werks nicht beeinträchtigt.", "legal_assessment_public_required": "Auf Grundlage der Angaben spricht vieles dafür, dass der KI-generierte oder manipulierte Text zu einer Angelegenheit von öffentlichem Interesse klar offengelegt werden sollte. Eine ausreichende substanzielle menschliche Prüfung/redaktionelle Kontrolle zusammen mit ausdrücklich übernommener redaktioneller Verantwortung ist nicht vollständig dokumentiert.", "legal_assessment_public_exemption": "Die Angaben dokumentieren eine substanzielle menschliche Prüfung oder redaktionelle Kontrolle sowie eine ausdrücklich benannte redaktionelle Verantwortung. Daher kann die Ausnahme für bestimmte Texte von öffentlichem Interesse nach Art. 50 Abs. 4 in Betracht kommen. Eine freiwillige Transparenzkennzeichnung bleibt möglich.", "legal_assessment_inconsistent": "Die gewählte rechtliche Kategorie passt nicht vollständig zu den strukturierten Inhaltsangaben. Bitte prüfe KI-Anteil und Inhaltsbestandteile.", "badge_legal_disclosure": "KI-generierter / manipulierter Inhalt", "badge_public_interest": "KI-generierte / bearbeitete Inhalte", "fact_legal_context": "Rechtlicher Kontext (Selbsteinordnung)", "field_responsibility_role": "Art der redaktionellen Verantwortung", "responsibility_none": "Nicht angegeben", "responsibility_publisher": "Veröffentlichende Person/Organisation übernimmt die redaktionelle Verantwortung", "responsibility_other": "Andere verantwortliche Stelle", "field_responsible": "Redaktionell verantwortliche Person/Organisation (optional)", "responsible_placeholder": "Name oder Organisation", "field_responsible_url": "Nachweis/Impressum zur redaktionellen Verantwortung (optional)", "field_language": "Ausgabesprache", "field_theme": "Darstellung", "theme_mono": "Monochrom", "theme_color": "Farbig",
|
||||
"field_subject": "URL des gekennzeichneten Inhalts", "field_language": "Ausgabesprache", "field_theme": "Darstellung", "theme_mono": "Monochrom", "theme_color": "Farbig",
|
||||
"preview": "Vorschau", "copy_html": "HTML kopieren", "copied": "Kopiert",
|
||||
"pro_eyebrow": "Pro-Anpassung", "pro_title": "Eigene Texte und Badge-Designs", "pro_enabled": "Diese Instanz besitzt eine gültige Pro-Lizenz. Individuelle Texte und Farben können verwendet werden.",
|
||||
"pro_locked": "Individuelle Titel, Beschreibungstexte, Badge-Beschriftungen und Farben sind in der Pro-Ausgabe verfügbar.",
|
||||
"pro_eyebrow": "Lizenzierte Anpassung", "pro_title": "Eigene Texte und Badge-Designs", "pro_enabled": "Diese Instanz verfügt über die erforderlichen lizenzierten Funktionen. Individuelle Texte und Farben können verwendet werden.",
|
||||
"pro_locked": "Individuelle Titel, Beschreibungstexte, Badge-Beschriftungen und Farben benötigen die entsprechende lizenzierte Capability.",
|
||||
"custom_title": "Eigener Erklärungstitel", "custom_description": "Eigene Beschreibung", "custom_badge_label": "Eigene Badge-Beschriftung links", "custom_badge_message": "Eigene Badge-Beschriftung rechts",
|
||||
"custom_left_color": "Farbe links", "custom_right_color": "Farbe rechts", "pro_required": "Pro-Lizenz erforderlich",
|
||||
"custom_left_color": "Farbe links", "custom_right_color": "Farbe rechts", "pro_required": "Lizenzierte Funktion erforderlich",
|
||||
"api_badge_title": "Badge-Endpunkt", "api_badge_desc": "Standard-Badges werden aus Presets und strukturierten Parametern erzeugt.",
|
||||
"api_manifest_title": "Manifest", "api_manifest_desc": "Das JSON-LD-Manifest kann direkt verlinkt, validiert oder in Build-Prozesse übernommen werden.",
|
||||
"api_stateless_title": "Zustandslos", "api_stateless_desc": "Keine Sessions und deterministische Antworten. Geeignet für horizontale Skalierung, Reverse Proxies und CDNs.",
|
||||
"footer_no_legal": "Keine Rechtsberatung.", "back": "Zurück", "declaration_eyebrow": "KI-Nutzungserklärung", "fact_component": "Komponente", "fact_extent": "KI-Anteil",
|
||||
"fact_activities": "Tätigkeiten", "fact_review": "Menschliche Prüfung", "fact_note": "Hinweis", "fact_assurance": "Nachweisniveau", "fact_subject": "Gekennzeichneter Inhalt",
|
||||
"fact_responsibility": "Redaktionelle Verantwortung", "fact_responsibility_type": "Rolle der redaktionellen Verantwortung", "fact_declared_at": "Erstellt", "none_value": "keine", "transparency_label": "Transparenzhinweis:",
|
||||
"transparency_text": "Diese Erklärung gibt die von der veröffentlichenden Person oder Organisation bereitgestellten Angaben zur KI-Nutzung wieder. Sie ersetzt weder eine rechtliche Bewertung noch eine Zertifizierung.",
|
||||
"review_legal_label": "Menschliche Prüfung:", "review_legal_text": "Ein angegebenes Prüfungsniveau ist eine Selbstauskunft zum tatsächlichen Arbeitsprozess. Es begründet für sich allein keine Ausnahme von einer Kennzeichnungspflicht nach Art. 50 Abs. 4 AI Act; hierfür müssen die gesetzlichen Voraussetzungen, insbesondere substanzielle menschliche Prüfung oder redaktionelle Kontrolle und redaktionelle Verantwortung, tatsächlich erfüllt sein.",
|
||||
"machine_marking_label": "Art. 50 Abs. 2:", "machine_marking_text": "Das verlinkte JSON-LD ist eine ergänzende Dokumentation. Es ist nicht als Ersatz für die vom Anbieter eines generativen KI-Systems geforderte wirksame, zuverlässige, robuste und interoperable maschinenlesbare Markierung der generierten oder manipulierten Ausgabe gedacht.",
|
||||
"ai_act_embed_label": "Platzierung nach Art. 50:", "ai_act_embed_text": "Bei einer gesetzlich erforderlichen Offenlegung muss der sichtbare Hinweis klar und unterscheidbar spätestens bei der ersten Exposition zum betroffenen Inhalt erscheinen. Ein Badge nur im Footer oder erst hinter einem späteren Link kann dafür zu spät sein. Bei Deepfake-Medien sollte die Kennzeichnung unmittelbar am Inhalt bzw. als gleichwertiges Overlay umgesetzt werden; der Code of Practice sieht für seine Signatories zudem vor, dass sie beim Teilen oder Download sichtbar bleibt.",
|
||||
"ai_act_machine_label": "Maschinenlesbarkeit:", "ai_act_machine_text": "Dieses JSON-LD dokumentiert die Nutzung ergänzend, erfüllt aber nicht automatisch die Anbieterpflicht zur technischen Markierung nach Art. 50 Abs. 2 AI Act.",
|
||||
"emoji_compliance_label": "Emoji-Variante:", "emoji_compliance_text": "Das quadratische Emoji ist ein kompakter Zusatz und kein offizielles EU-Kennzeichnungssymbol. Bei einer gesetzlichen Offenlegung sollte es nicht ohne einen unmittelbar verständlichen Text oder eine gleichwertig klare Kennzeichnung verwendet werden.",
|
||||
"manifest": "Maschinenlesbares Manifest",
|
||||
"fact_responsibility": "Redaktionelle Verantwortung", "fact_declared_at": "Erstellt", "none_value": "keine", "transparency_label": "Transparenzhinweis:",
|
||||
"transparency_text": "Diese Erklärung beschreibt die angegebene Nutzung von KI. Sie ist keine Lizenz, Zertifizierung oder Rechtsberatung.", "manifest": "Maschinenlesbares Manifest", "bundle": "Export-Bundle",
|
||||
"ai_label": "KI-Nutzung", "capabilities": "Funktionen",
|
||||
},
|
||||
map[string]PresetText{
|
||||
"no-ai": {"Kein Einsatz von KI", "Für die Erstellung des gekennzeichneten Inhalts wurde nach den gemachten Angaben keine generative KI eingesetzt."},
|
||||
"research": {"Rechercheunterstützung", "KI wurde unterstützend zur Recherche oder zum Auffinden relevanter Quellen eingesetzt. Eine menschliche Prüfung wird nur ausgewiesen, wenn sie ausdrücklich ausgewählt wurde."},
|
||||
"summary": {"Inhaltliche Zusammenfassung", "KI wurde zur Zusammenfassung von Quellen eingesetzt. Eine menschliche Prüfung oder redaktionelle Kontrolle wird nicht automatisch unterstellt."},
|
||||
"full": {"Vollständige Inhaltserstellung", "Der gekennzeichnete Inhalt wurde überwiegend oder vollständig mit generativer KI erstellt. Ein tatsächlicher menschlicher Prüfprozess ist gesondert anzugeben."},
|
||||
"no-ai": {"Kein Einsatz von KI", "Für die Erstellung des gekennzeichneten Inhalts wurde keine generative KI eingesetzt. Die inhaltliche Verantwortung und Prüfung lagen bei Menschen."},
|
||||
"research": {"Rechercheunterstützung", "KI wurde unterstützend zur Recherche und zum Auffinden relevanter Quellen eingesetzt. Auswahl, Einordnung und redaktionelle Ausarbeitung erfolgten durch Menschen."},
|
||||
"summary": {"Inhaltliche Zusammenfassung", "KI wurde zur Zusammenfassung von Quellen eingesetzt. Auswahl, fachliche Einordnung und Endfassung wurden durch Menschen verantwortet und geprüft."},
|
||||
"full": {"Vollständige Inhaltserstellung", "Der gekennzeichnete Inhalt wurde überwiegend oder vollständig mit generativer KI erstellt. Die veröffentlichte Fassung wurde anschließend menschlich geprüft und redaktionell verantwortet."},
|
||||
},
|
||||
map[string]string{"none": "Kein Einsatz von KI", "assisted": "KI-unterstützt", "partial": "Teilweise KI-generiert", "mostly": "Überwiegend KI-generiert", "full": "Vollständig KI-generiert"},
|
||||
map[string]string{"text": "Text", "coverImage": "Titelbild", "image": "Bild", "audio": "Audio", "video": "Video", "code": "Code", "other": "Sonstiges"},
|
||||
map[string]string{"none": "Keine dokumentiert", "basic": "Grundlegend/formal", "editorial": "Inhaltlich-redaktionell", "expert": "Fachlich-inhaltlich"},
|
||||
map[string]string{"none": "Keine", "basic": "Grundlegend", "editorial": "Redaktionell", "expert": "Fachlich"},
|
||||
map[string]string{"research": "Recherche", "summarisation": "Zusammenfassung", "drafting": "Entwurf", "generation": "Erzeugung", "translation": "Übersetzung", "editing": "Überarbeitung", "imageGeneration": "Bilderzeugung", "codeGeneration": "Codeerzeugung", "transcription": "Transkription", "classification": "Klassifikation"},
|
||||
map[string]string{"selfDeclared": "Selbsterklärung", "technicallyRecorded": "Technisch protokolliert", "signed": "Signiert", "verified": "Verifiziert"}),
|
||||
"en": locale("en", "English", map[string]string{
|
||||
@@ -74,18 +67,18 @@ var catalogs = map[string]Locale{
|
||||
"standard_eyebrow": "Open disclosure standard", "hero_title": "Disclose how AI contributed to content.", "hero_lead": "A visible SVG badge, a human-readable declaration page and machine-readable JSON-LD – without cookies, tracking or a database requirement.",
|
||||
"generator_eyebrow": "Generator", "generator_title": "Create an embed", "generator_intro": "Choose a template or record AI use systematically by content component. The preview, embed code and machine-readable declaration are generated immediately.",
|
||||
"field_preset": "Preset", "option_custom": "Custom structure", "field_component": "Component", "field_extent": "AI contribution", "field_review": "Human review", "field_activities": "Activities",
|
||||
"activities_help": "Comma-separated standard values such as research, summarisation or translation.", "review_help": "Record only the review process that actually took place. A merely formal check (for example spelling or grammar) is not substantive human review or editorial control for the purposes of Article 50(4) AI Act.", "field_subject": "URL of the labelled content", "field_responsibility_role": "Editorial responsibility type", "responsibility_none": "Not specified", "responsibility_publisher": "Publishing person/organisation assumes editorial responsibility", "responsibility_other": "Other responsible party", "field_responsible": "Editorially responsible person/organisation (optional)", "responsible_placeholder": "Name or organisation", "field_responsible_url": "Evidence/imprint for editorial responsibility (optional)", "field_language": "Output language", "field_theme": "Appearance", "theme_mono": "Monochrome", "theme_color": "Colour",
|
||||
"preview": "Preview", "copy_html": "Copy HTML", "copied": "Copied", "pro_eyebrow": "Pro customisation", "pro_title": "Custom copy and badge designs",
|
||||
"pro_enabled": "This instance has a valid Pro licence. Custom copy and colours are available.", "pro_locked": "Custom titles, descriptions, badge labels and colours are available in the Pro edition.",
|
||||
"custom_title": "Custom declaration title", "custom_description": "Custom description", "custom_badge_label": "Custom left badge label", "custom_badge_message": "Custom right badge label", "custom_left_color": "Left colour", "custom_right_color": "Right colour", "pro_required": "Pro licence required",
|
||||
"api_badge_title": "Badge endpoint", "api_badge_desc": "Standard badges are generated from presets and structured parameters. Pro adds custom copy and colours.", "api_manifest_title": "Manifest", "api_manifest_desc": "The JSON-LD manifest can be linked, validated or included in build pipelines.",
|
||||
"activities_help": "Comma-separated standard values such as research, summarisation or translation.", "field_subject": "URL of the labelled content", "field_language": "Output language", "field_theme": "Appearance", "theme_mono": "Monochrome", "theme_color": "Colour",
|
||||
"preview": "Preview", "copy_html": "Copy HTML", "copied": "Copied", "pro_eyebrow": "Licensed customisation", "pro_title": "Custom copy and badge designs",
|
||||
"pro_enabled": "This instance has the required licensed capabilities. Custom copy and colours are available.", "pro_locked": "Custom titles, descriptions, badge labels and colours require the corresponding licensed capability.",
|
||||
"custom_title": "Custom declaration title", "custom_description": "Custom description", "custom_badge_label": "Custom left badge label", "custom_badge_message": "Custom right badge label", "custom_left_color": "Left colour", "custom_right_color": "Right colour", "pro_required": "Licensed capability required",
|
||||
"api_badge_title": "Badge endpoint", "api_badge_desc": "Standard badges are generated from presets and structured parameters. Licensed capabilities can additionally enable custom copy and colours.", "api_manifest_title": "Manifest", "api_manifest_desc": "The JSON-LD manifest can be linked, validated or included in build pipelines.",
|
||||
"api_stateless_title": "Stateless", "api_stateless_desc": "No sessions and deterministic responses. Designed for horizontal scaling, reverse proxies and CDNs.", "footer_no_legal": "Not legal advice.",
|
||||
"back": "Back", "declaration_eyebrow": "AI usage declaration", "fact_component": "Component", "fact_extent": "AI contribution", "fact_activities": "Activities", "fact_review": "Human review", "fact_note": "Note", "fact_assurance": "Assurance level", "fact_subject": "Labelled content", "fact_responsibility": "Editorial responsibility", "fact_responsibility_type": "Editorial responsibility role", "fact_declared_at": "Declared at", "none_value": "none", "transparency_label": "Transparency notice:", "transparency_text": "This declaration presents the information on AI use supplied by the publishing person or organisation. It does not replace a legal assessment or certification.", "review_legal_label": "Human review:", "review_legal_text": "A stated review level is a self-declaration of the process that actually took place. By itself it does not establish an exemption from a labelling duty under Article 50(4) AI Act; the statutory conditions, including substantive human review or editorial control and editorial responsibility, must actually be met.", "machine_marking_label": "Article 50(2):", "machine_marking_text": "The linked JSON-LD is supplementary documentation. It is not intended to replace the effective, reliable, robust and interoperable machine-readable marking of generated or manipulated output required from the provider of an in-scope generative AI system.", "ai_act_embed_label": "Article 50 placement:", "ai_act_embed_text": "Where disclosure is legally required, the visible notice must be clear and distinguishable no later than first exposure to the affected content. A badge shown only in a footer or behind a later link may be too late. For deepfake media, the notice should be attached directly to the content or provided through an equivalent overlay; the Code of Practice additionally provides for visibility when content is reshared or downloaded for its signatories.", "ai_act_machine_label": "Machine readability:", "ai_act_machine_text": "This JSON-LD supplements documentation but does not automatically satisfy the provider-side technical marking duty under Article 50(2) AI Act.", "emoji_compliance_label": "Emoji variant:", "emoji_compliance_text": "The square emoji is a compact supplement and is not an official EU labelling icon. Where a legal disclosure is required, do not rely on it without immediately understandable text or an equivalently clear disclosure.", "manifest": "Machine-readable manifest", "ai_label": "AI use", "capabilities": "Capabilities",
|
||||
"back": "Back", "declaration_eyebrow": "AI usage declaration", "fact_component": "Component", "fact_extent": "AI contribution", "fact_activities": "Activities", "fact_review": "Human review", "fact_note": "Note", "fact_assurance": "Assurance level", "fact_subject": "Labelled content", "fact_responsibility": "Editorial responsibility", "fact_declared_at": "Declared at", "none_value": "none", "transparency_label": "Transparency notice:", "transparency_text": "This declaration describes the stated use of AI. It is not a licence, certification or legal advice.", "manifest": "Machine-readable manifest", "bundle": "Export bundle", "ai_label": "AI use", "capabilities": "Capabilities",
|
||||
},
|
||||
map[string]PresetText{"no-ai": {"No AI use", "According to the information supplied, no generative AI was used to create the labelled content."}, "research": {"Research assistance", "AI was used to support research or identify relevant sources. Human review is shown only when explicitly selected."}, "summary": {"Content summarisation", "AI was used to summarise source material. Human review or editorial control is not presumed automatically."}, "full": {"Full content generation", "The labelled content was created mostly or entirely with generative AI. Any actual human review process must be stated separately."}},
|
||||
map[string]PresetText{"no-ai": {"No AI use", "No generative AI was used to create the labelled content. Responsibility for the content and its review remained with people."}, "research": {"Research assistance", "AI was used to support research and identify relevant sources. Selection, assessment and editorial preparation were carried out by people."}, "summary": {"Content summarisation", "AI was used to summarise source material. Selection, contextual assessment and the final version were the responsibility of human editors."}, "full": {"Full content generation", "The labelled content was created mostly or entirely with generative AI. The published version was subsequently reviewed and editorially approved by people."}},
|
||||
map[string]string{"none": "No AI use", "assisted": "AI-assisted", "partial": "Partially AI-generated", "mostly": "Mostly AI-generated", "full": "Fully AI-generated"},
|
||||
map[string]string{"text": "Text", "coverImage": "Cover image", "image": "Image", "audio": "Audio", "video": "Video", "code": "Code", "other": "Other"},
|
||||
map[string]string{"none": "None recorded", "basic": "Basic/formal", "editorial": "Substantive editorial", "expert": "Substantive expert"},
|
||||
map[string]string{"none": "None", "basic": "Basic", "editorial": "Editorial", "expert": "Expert"},
|
||||
map[string]string{"research": "Research", "summarisation": "Summarisation", "drafting": "Drafting", "generation": "Generation", "translation": "Translation", "editing": "Editing", "imageGeneration": "Image generation", "codeGeneration": "Code generation", "transcription": "Transcription", "classification": "Classification"},
|
||||
map[string]string{"selfDeclared": "Self-declared", "technicallyRecorded": "Technically recorded", "signed": "Signed", "verified": "Verified"}),
|
||||
"fr": europeanLocale("fr", "Français", "Déclarer comment l’IA a contribué à un contenu.", "Utilisation de l’IA", "Aucune utilisation de l’IA", "Assisté par l’IA", "Partiellement généré par l’IA", "Majoritairement généré par l’IA", "Entièrement généré par l’IA",
|
||||
@@ -142,25 +135,9 @@ func init() {
|
||||
applyEuropeanTaxonomyTranslations()
|
||||
applyArticleTranslations()
|
||||
applyAssuranceTranslations()
|
||||
applyThemeTranslations()
|
||||
}
|
||||
|
||||
func applyThemeTranslations() {
|
||||
values := map[string]string{
|
||||
"de": "Emoji (quadratisch)",
|
||||
"en": "Emoji (square)",
|
||||
"fr": "Emoji (carré)",
|
||||
"es": "Emoji (cuadrado)",
|
||||
"it": "Emoji (quadrato)",
|
||||
"nl": "Emoji (vierkant)",
|
||||
"pt": "Emoji (quadrado)",
|
||||
"pl": "Emoji (kwadratowe)",
|
||||
}
|
||||
for code, value := range values {
|
||||
l := catalogs[code]
|
||||
l.Text["theme_emoji"] = value
|
||||
catalogs[code] = l
|
||||
}
|
||||
applyArticle50Translations()
|
||||
applyBulkTranslations()
|
||||
applyUCNGBrandCopy()
|
||||
}
|
||||
|
||||
func applyArticleTranslations() {
|
||||
@@ -200,32 +177,32 @@ func europeanTextOverrides() map[string]map[string]string {
|
||||
"generator_eyebrow": "Générateur", "generator_title": "Créer un code d’intégration", "generator_intro": "Choisissez un modèle ou composez une déclaration détaillée. L’aperçu et le code sont générés directement dans le navigateur.",
|
||||
"field_preset": "Modèle", "option_custom": "Structure personnalisée", "field_component": "Composant", "field_extent": "Contribution de l’IA", "field_review": "Vérification humaine", "field_activities": "Activités", "activities_help": "Valeurs standard séparées par des virgules, par exemple research, summarisation ou translation.",
|
||||
"field_subject": "URL du contenu identifié", "field_language": "Langue de sortie", "field_theme": "Apparence", "theme_mono": "Monochrome", "theme_color": "Couleur", "preview": "Aperçu", "copy_html": "Copier le HTML", "copied": "Copié",
|
||||
"pro_eyebrow": "Personnalisation Pro", "pro_title": "Textes et badges personnalisés", "pro_enabled": "Cette instance dispose d’une licence Pro valide. Les textes et couleurs personnalisés sont disponibles.", "pro_locked": "Les titres, descriptions, libellés et couleurs personnalisés sont disponibles dans l’édition Pro.",
|
||||
"custom_title": "Titre personnalisé", "custom_description": "Description personnalisée", "custom_badge_label": "Libellé gauche du badge", "custom_badge_message": "Libellé droit du badge", "custom_left_color": "Couleur gauche", "custom_right_color": "Couleur droite", "pro_required": "Licence Pro requise",
|
||||
"api_badge_title": "Point d’accès badge", "api_badge_desc": "Les badges standard utilisent les modèles et paramètres structurés. Pro ajoute des textes et couleurs personnalisés.", "api_manifest_title": "Manifeste", "api_manifest_desc": "Le manifeste JSON-LD peut être lié, validé ou intégré à un processus de build.", "api_stateless_title": "Sans état", "api_stateless_desc": "Aucune session et des réponses déterministes, adaptées à la mise à l’échelle horizontale, aux proxys inverses et aux CDN.",
|
||||
"footer_no_legal": "Ne constitue pas un conseil juridique.", "back": "Retour", "declaration_eyebrow": "Déclaration d’utilisation de l’IA", "fact_component": "Composant", "fact_extent": "Contribution de l’IA", "fact_activities": "Activités", "fact_review": "Vérification humaine", "fact_note": "Note", "fact_assurance": "Niveau d’assurance", "fact_subject": "Contenu identifié", "fact_responsibility": "Responsabilité éditoriale", "fact_declared_at": "Déclaré le", "none_value": "aucune", "transparency_label": "Avis de transparence :", "transparency_text": "Cette déclaration décrit l’utilisation déclarée de l’IA. Elle ne constitue ni une licence, ni une certification, ni un conseil juridique.", "manifest": "Manifeste lisible par machine",
|
||||
"pro_eyebrow": "Personnalisation sous licence", "pro_title": "Textes et badges personnalisés", "pro_enabled": "Cette instance dispose des fonctions sous licence requises. Les textes et couleurs personnalisés sont disponibles.", "pro_locked": "Les titres, descriptions, libellés et couleurs personnalisés nécessitent la fonctionnalité sous licence correspondante.",
|
||||
"custom_title": "Titre personnalisé", "custom_description": "Description personnalisée", "custom_badge_label": "Libellé gauche du badge", "custom_badge_message": "Libellé droit du badge", "custom_left_color": "Couleur gauche", "custom_right_color": "Couleur droite", "pro_required": "Fonction sous licence requise",
|
||||
"api_badge_title": "Point d’accès badge", "api_badge_desc": "Les badges standard utilisent les modèles et paramètres structurés. Des fonctionnalités sous licence peuvent activer des textes et couleurs personnalisés.", "api_manifest_title": "Manifeste", "api_manifest_desc": "Le manifeste JSON-LD peut être lié, validé ou intégré à un processus de build.", "api_stateless_title": "Sans état", "api_stateless_desc": "Aucune session et des réponses déterministes, adaptées à la mise à l’échelle horizontale, aux proxys inverses et aux CDN.",
|
||||
"footer_no_legal": "Ne constitue pas un conseil juridique.", "back": "Retour", "declaration_eyebrow": "Déclaration d’utilisation de l’IA", "fact_component": "Composant", "fact_extent": "Contribution de l’IA", "fact_activities": "Activités", "fact_review": "Vérification humaine", "fact_note": "Note", "fact_assurance": "Niveau d’assurance", "fact_subject": "Contenu identifié", "fact_responsibility": "Responsabilité éditoriale", "fact_declared_at": "Déclaré le", "none_value": "aucune", "transparency_label": "Avis de transparence :", "transparency_text": "Cette déclaration décrit l’utilisation déclarée de l’IA. Elle ne constitue ni une licence, ni une certification, ni un conseil juridique.", "manifest": "Manifeste lisible par machine", "bundle": "Bundle d’export",
|
||||
},
|
||||
"es": {
|
||||
"meta_description": "Declaración abierta, legible por máquina y autoalojable del uso de IA.", "nav_generator": "Generador", "nav_background": "Contexto", "standard_eyebrow": "Estándar abierto de transparencia", "hero_lead": "Insignia SVG visible, página explicativa y JSON-LD legible por máquina, sin cookies, seguimiento ni obligación de base de datos.",
|
||||
"generator_eyebrow": "Generador", "generator_title": "Crear código de inserción", "generator_intro": "Elige un preajuste o crea una declaración detallada. La vista previa y el código se generan directamente en el navegador.", "field_preset": "Preajuste", "option_custom": "Estructura personalizada", "field_component": "Componente", "field_extent": "Contribución de la IA", "field_review": "Revisión humana", "field_activities": "Actividades", "activities_help": "Valores estándar separados por comas, por ejemplo research, summarisation o translation.", "field_subject": "URL del contenido identificado", "field_language": "Idioma de salida", "field_theme": "Apariencia", "theme_mono": "Monocromo", "theme_color": "Color", "preview": "Vista previa", "copy_html": "Copiar HTML", "copied": "Copiado",
|
||||
"pro_eyebrow": "Personalización Pro", "pro_title": "Textos y diseños de insignia propios", "pro_enabled": "Esta instancia tiene una licencia Pro válida. Se pueden usar textos y colores personalizados.", "pro_locked": "Los títulos, descripciones, etiquetas y colores personalizados están disponibles en la edición Pro.", "custom_title": "Título personalizado", "custom_description": "Descripción personalizada", "custom_badge_label": "Etiqueta izquierda de la insignia", "custom_badge_message": "Etiqueta derecha de la insignia", "custom_left_color": "Color izquierdo", "custom_right_color": "Color derecho", "pro_required": "Se requiere licencia Pro",
|
||||
"api_badge_title": "Endpoint de insignias", "api_badge_desc": "Las insignias estándar se generan con preajustes y parámetros estructurados. Pro añade textos y colores propios.", "api_manifest_title": "Manifiesto", "api_manifest_desc": "El manifiesto JSON-LD puede enlazarse, validarse o integrarse en procesos de compilación.", "api_stateless_title": "Sin estado", "api_stateless_desc": "Sin sesiones y con respuestas deterministas. Preparado para escalado horizontal, proxies inversos y CDN.", "footer_no_legal": "No constituye asesoramiento jurídico.", "back": "Volver", "declaration_eyebrow": "Declaración de uso de IA", "fact_component": "Componente", "fact_extent": "Contribución de la IA", "fact_activities": "Actividades", "fact_review": "Revisión humana", "fact_note": "Nota", "fact_assurance": "Nivel de garantía", "fact_subject": "Contenido identificado", "fact_responsibility": "Responsabilidad editorial", "fact_declared_at": "Declarado el", "none_value": "ninguna", "transparency_label": "Aviso de transparencia:", "transparency_text": "Esta declaración describe el uso indicado de IA. No es una licencia, certificación ni asesoramiento jurídico.", "manifest": "Manifiesto legible por máquina",
|
||||
"pro_eyebrow": "Personalización con licencia", "pro_title": "Textos y diseños de insignia propios", "pro_enabled": "Esta instancia dispone de las funciones con licencia necesarias. Se pueden usar textos y colores personalizados.", "pro_locked": "Los títulos, descripciones, etiquetas y colores personalizados requieren la función con licencia correspondiente.", "custom_title": "Título personalizado", "custom_description": "Descripción personalizada", "custom_badge_label": "Etiqueta izquierda de la insignia", "custom_badge_message": "Etiqueta derecha de la insignia", "custom_left_color": "Color izquierdo", "custom_right_color": "Color derecho", "pro_required": "Se requiere una función con licencia",
|
||||
"api_badge_title": "Endpoint de insignias", "api_badge_desc": "Las insignias estándar se generan con preajustes y parámetros estructurados. Las funciones con licencia pueden habilitar textos y colores personalizados.", "api_manifest_title": "Manifiesto", "api_manifest_desc": "El manifiesto JSON-LD puede enlazarse, validarse o integrarse en procesos de compilación.", "api_stateless_title": "Sin estado", "api_stateless_desc": "Sin sesiones y con respuestas deterministas. Preparado para escalado horizontal, proxies inversos y CDN.", "footer_no_legal": "No constituye asesoramiento jurídico.", "back": "Volver", "declaration_eyebrow": "Declaración de uso de IA", "fact_component": "Componente", "fact_extent": "Contribución de la IA", "fact_activities": "Actividades", "fact_review": "Revisión humana", "fact_note": "Nota", "fact_assurance": "Nivel de garantía", "fact_subject": "Contenido identificado", "fact_responsibility": "Responsabilidad editorial", "fact_declared_at": "Declarado el", "none_value": "ninguna", "transparency_label": "Aviso de transparencia:", "transparency_text": "Esta declaración describe el uso indicado de IA. No es una licencia, certificación ni asesoramiento jurídico.", "manifest": "Manifiesto legible por máquina", "bundle": "Paquete de exportación",
|
||||
},
|
||||
"it": {
|
||||
"meta_description": "Dichiarazione aperta, leggibile dalle macchine e auto-ospitabile dell’uso dell’IA.", "nav_generator": "Generatore", "nav_background": "Contesto", "standard_eyebrow": "Standard aperto di trasparenza", "hero_lead": "Badge SVG visibile, pagina esplicativa e JSON-LD leggibile dalle macchine, senza cookie, tracciamento o obbligo di database.", "generator_eyebrow": "Generatore", "generator_title": "Crea un codice di incorporamento", "generator_intro": "Scegli un preset o componi una dichiarazione dettagliata. Anteprima e codice vengono generati direttamente nel browser.", "field_preset": "Preset", "option_custom": "Struttura personalizzata", "field_component": "Componente", "field_extent": "Contributo dell’IA", "field_review": "Revisione umana", "field_activities": "Attività", "activities_help": "Valori standard separati da virgole, ad esempio research, summarisation o translation.", "field_subject": "URL del contenuto identificato", "field_language": "Lingua di output", "field_theme": "Aspetto", "theme_mono": "Monocromatico", "theme_color": "Colore", "preview": "Anteprima", "copy_html": "Copia HTML", "copied": "Copiato",
|
||||
"pro_eyebrow": "Personalizzazione Pro", "pro_title": "Testi e badge personalizzati", "pro_enabled": "Questa istanza dispone di una licenza Pro valida. Sono disponibili testi e colori personalizzati.", "pro_locked": "Titoli, descrizioni, etichette e colori personalizzati sono disponibili nell’edizione Pro.", "custom_title": "Titolo personalizzato", "custom_description": "Descrizione personalizzata", "custom_badge_label": "Etichetta sinistra del badge", "custom_badge_message": "Etichetta destra del badge", "custom_left_color": "Colore sinistro", "custom_right_color": "Colore destro", "pro_required": "Licenza Pro richiesta", "api_badge_title": "Endpoint badge", "api_badge_desc": "I badge standard usano preset e parametri strutturati. Pro aggiunge testi e colori personalizzati.", "api_manifest_title": "Manifesto", "api_manifest_desc": "Il manifesto JSON-LD può essere collegato, validato o inserito nei processi di build.", "api_stateless_title": "Senza stato", "api_stateless_desc": "Nessuna sessione e risposte deterministiche, adatte a scalabilità orizzontale, reverse proxy e CDN.", "footer_no_legal": "Non costituisce consulenza legale.", "back": "Indietro", "declaration_eyebrow": "Dichiarazione d’uso dell’IA", "fact_component": "Componente", "fact_extent": "Contributo dell’IA", "fact_activities": "Attività", "fact_review": "Revisione umana", "fact_note": "Nota", "fact_assurance": "Livello di garanzia", "fact_subject": "Contenuto identificato", "fact_responsibility": "Responsabilità editoriale", "fact_declared_at": "Dichiarato il", "none_value": "nessuna", "transparency_label": "Avviso di trasparenza:", "transparency_text": "Questa dichiarazione descrive l’uso dichiarato dell’IA. Non è una licenza, una certificazione o una consulenza legale.", "manifest": "Manifesto leggibile dalle macchine",
|
||||
"pro_eyebrow": "Personalizzazione con licenza", "pro_title": "Testi e badge personalizzati", "pro_enabled": "Questa istanza dispone delle funzionalità con licenza richieste. Sono disponibili testi e colori personalizzati.", "pro_locked": "Titoli, descrizioni, etichette e colori personalizzati richiedono la relativa funzionalità con licenza.", "custom_title": "Titolo personalizzato", "custom_description": "Descrizione personalizzata", "custom_badge_label": "Etichetta sinistra del badge", "custom_badge_message": "Etichetta destra del badge", "custom_left_color": "Colore sinistro", "custom_right_color": "Colore destro", "pro_required": "Funzionalità con licenza richiesta", "api_badge_title": "Endpoint badge", "api_badge_desc": "I badge standard usano preset e parametri strutturati. Le funzionalità con licenza possono abilitare testi e colori personalizzati.", "api_manifest_title": "Manifesto", "api_manifest_desc": "Il manifesto JSON-LD può essere collegato, validato o inserito nei processi di build.", "api_stateless_title": "Senza stato", "api_stateless_desc": "Nessuna sessione e risposte deterministiche, adatte a scalabilità orizzontale, reverse proxy e CDN.", "footer_no_legal": "Non costituisce consulenza legale.", "back": "Indietro", "declaration_eyebrow": "Dichiarazione d’uso dell’IA", "fact_component": "Componente", "fact_extent": "Contributo dell’IA", "fact_activities": "Attività", "fact_review": "Revisione umana", "fact_note": "Nota", "fact_assurance": "Livello di garanzia", "fact_subject": "Contenuto identificato", "fact_responsibility": "Responsabilità editoriale", "fact_declared_at": "Dichiarato il", "none_value": "nessuna", "transparency_label": "Avviso di trasparenza:", "transparency_text": "Questa dichiarazione descrive l’uso dichiarato dell’IA. Non è una licenza, una certificazione o una consulenza legale.", "manifest": "Manifesto leggibile dalle macchine", "bundle": "Pacchetto di esportazione",
|
||||
},
|
||||
"nl": {
|
||||
"meta_description": "Open, machineleesbare en zelf te hosten verklaring van AI-gebruik.", "nav_generator": "Generator", "nav_background": "Achtergrond", "standard_eyebrow": "Open transparantiestandaard", "hero_lead": "Zichtbare SVG-badge, begrijpelijke uitlegpagina en machineleesbare JSON-LD, zonder cookies, tracking of verplichte database.", "generator_eyebrow": "Generator", "generator_title": "Insluitcode maken", "generator_intro": "Kies een preset of stel een gedetailleerde verklaring samen. Voorbeeld en code worden direct in de browser gemaakt.", "field_preset": "Preset", "option_custom": "Aangepaste structuur", "field_component": "Onderdeel", "field_extent": "AI-bijdrage", "field_review": "Menselijke controle", "field_activities": "Activiteiten", "activities_help": "Door komma’s gescheiden standaardwaarden, zoals research, summarisation of translation.", "field_subject": "URL van de gemarkeerde inhoud", "field_language": "Uitvoertaal", "field_theme": "Weergave", "theme_mono": "Monochroom", "theme_color": "Kleur", "preview": "Voorbeeld", "copy_html": "HTML kopiëren", "copied": "Gekopieerd",
|
||||
"pro_eyebrow": "Pro-aanpassing", "pro_title": "Eigen teksten en badge-ontwerpen", "pro_enabled": "Deze instantie heeft een geldige Pro-licentie. Eigen teksten en kleuren zijn beschikbaar.", "pro_locked": "Eigen titels, beschrijvingen, labels en kleuren zijn beschikbaar in de Pro-editie.", "custom_title": "Eigen titel", "custom_description": "Eigen beschrijving", "custom_badge_label": "Linker badgelabel", "custom_badge_message": "Rechter badgelabel", "custom_left_color": "Linkerkleur", "custom_right_color": "Rechterkleur", "pro_required": "Pro-licentie vereist", "api_badge_title": "Badge-endpoint", "api_badge_desc": "Standaardbadges gebruiken presets en gestructureerde parameters. Pro voegt eigen teksten en kleuren toe.", "api_manifest_title": "Manifest", "api_manifest_desc": "Het JSON-LD-manifest kan worden gekoppeld, gevalideerd of in buildprocessen worden opgenomen.", "api_stateless_title": "Stateless", "api_stateless_desc": "Geen sessies en deterministische antwoorden, geschikt voor horizontale schaal, reverse proxies en CDN’s.", "footer_no_legal": "Geen juridisch advies.", "back": "Terug", "declaration_eyebrow": "Verklaring van AI-gebruik", "fact_component": "Onderdeel", "fact_extent": "AI-bijdrage", "fact_activities": "Activiteiten", "fact_review": "Menselijke controle", "fact_note": "Opmerking", "fact_assurance": "Zekerheidsniveau", "fact_subject": "Gemarkeerde inhoud", "fact_responsibility": "Redactionele verantwoordelijkheid", "fact_declared_at": "Verklaard op", "none_value": "geen", "transparency_label": "Transparantiemelding:", "transparency_text": "Deze verklaring beschrijft het opgegeven AI-gebruik. Het is geen licentie, certificering of juridisch advies.", "manifest": "Machineleesbaar manifest",
|
||||
"pro_eyebrow": "Gelicentieerde aanpassing", "pro_title": "Eigen teksten en badge-ontwerpen", "pro_enabled": "Deze instantie beschikt over de vereiste gelicentieerde functies. Eigen teksten en kleuren zijn beschikbaar.", "pro_locked": "Eigen titels, beschrijvingen, labels en kleuren vereisen de bijbehorende gelicentieerde functie.", "custom_title": "Eigen titel", "custom_description": "Eigen beschrijving", "custom_badge_label": "Linker badgelabel", "custom_badge_message": "Rechter badgelabel", "custom_left_color": "Linkerkleur", "custom_right_color": "Rechterkleur", "pro_required": "Gelicentieerde functie vereist", "api_badge_title": "Badge-endpoint", "api_badge_desc": "Standaardbadges gebruiken presets en gestructureerde parameters. Gelicentieerde functies kunnen eigen teksten en kleuren inschakelen.", "api_manifest_title": "Manifest", "api_manifest_desc": "Het JSON-LD-manifest kan worden gekoppeld, gevalideerd of in buildprocessen worden opgenomen.", "api_stateless_title": "Stateless", "api_stateless_desc": "Geen sessies en deterministische antwoorden, geschikt voor horizontale schaal, reverse proxies en CDN’s.", "footer_no_legal": "Geen juridisch advies.", "back": "Terug", "declaration_eyebrow": "Verklaring van AI-gebruik", "fact_component": "Onderdeel", "fact_extent": "AI-bijdrage", "fact_activities": "Activiteiten", "fact_review": "Menselijke controle", "fact_note": "Opmerking", "fact_assurance": "Zekerheidsniveau", "fact_subject": "Gemarkeerde inhoud", "fact_responsibility": "Redactionele verantwoordelijkheid", "fact_declared_at": "Verklaard op", "none_value": "geen", "transparency_label": "Transparantiemelding:", "transparency_text": "Deze verklaring beschrijft het opgegeven AI-gebruik. Het is geen licentie, certificering of juridisch advies.", "manifest": "Machineleesbaar manifest", "bundle": "Exportbundel",
|
||||
},
|
||||
"pt": {
|
||||
"meta_description": "Declaração aberta, legível por máquina e autoalojável do uso de IA.", "nav_generator": "Gerador", "nav_background": "Contexto", "standard_eyebrow": "Padrão aberto de transparência", "hero_lead": "Badge SVG visível, página explicativa e JSON-LD legível por máquina, sem cookies, rastreamento ou obrigação de base de dados.", "generator_eyebrow": "Gerador", "generator_title": "Criar código de incorporação", "generator_intro": "Escolha uma predefinição ou crie uma declaração detalhada. A pré-visualização e o código são gerados diretamente no navegador.", "field_preset": "Predefinição", "option_custom": "Estrutura personalizada", "field_component": "Componente", "field_extent": "Contributo da IA", "field_review": "Revisão humana", "field_activities": "Atividades", "activities_help": "Valores padrão separados por vírgulas, por exemplo research, summarisation ou translation.", "field_subject": "URL do conteúdo identificado", "field_language": "Idioma de saída", "field_theme": "Aspeto", "theme_mono": "Monocromático", "theme_color": "Cor", "preview": "Pré-visualização", "copy_html": "Copiar HTML", "copied": "Copiado",
|
||||
"pro_eyebrow": "Personalização Pro", "pro_title": "Textos e badges personalizados", "pro_enabled": "Esta instância tem uma licença Pro válida. Estão disponíveis textos e cores personalizados.", "pro_locked": "Títulos, descrições, etiquetas e cores personalizados estão disponíveis na edição Pro.", "custom_title": "Título personalizado", "custom_description": "Descrição personalizada", "custom_badge_label": "Etiqueta esquerda do badge", "custom_badge_message": "Etiqueta direita do badge", "custom_left_color": "Cor esquerda", "custom_right_color": "Cor direita", "pro_required": "Licença Pro necessária", "api_badge_title": "Endpoint de badge", "api_badge_desc": "Os badges padrão usam predefinições e parâmetros estruturados. Pro adiciona textos e cores personalizados.", "api_manifest_title": "Manifesto", "api_manifest_desc": "O manifesto JSON-LD pode ser ligado, validado ou integrado em processos de build.", "api_stateless_title": "Sem estado", "api_stateless_desc": "Sem sessões e com respostas determinísticas, adequado a escalabilidade horizontal, proxies inversos e CDN.", "footer_no_legal": "Não constitui aconselhamento jurídico.", "back": "Voltar", "declaration_eyebrow": "Declaração de uso de IA", "fact_component": "Componente", "fact_extent": "Contributo da IA", "fact_activities": "Atividades", "fact_review": "Revisão humana", "fact_note": "Nota", "fact_assurance": "Nível de garantia", "fact_subject": "Conteúdo identificado", "fact_responsibility": "Responsabilidade editorial", "fact_declared_at": "Declarado em", "none_value": "nenhuma", "transparency_label": "Aviso de transparência:", "transparency_text": "Esta declaração descreve o uso indicado de IA. Não é uma licença, certificação ou aconselhamento jurídico.", "manifest": "Manifesto legível por máquina",
|
||||
"pro_eyebrow": "Personalização licenciada", "pro_title": "Textos e badges personalizados", "pro_enabled": "Esta instância dispõe das funcionalidades licenciadas necessárias. Estão disponíveis textos e cores personalizados.", "pro_locked": "Títulos, descrições, etiquetas e cores personalizados exigem a funcionalidade licenciada correspondente.", "custom_title": "Título personalizado", "custom_description": "Descrição personalizada", "custom_badge_label": "Etiqueta esquerda do badge", "custom_badge_message": "Etiqueta direita do badge", "custom_left_color": "Cor esquerda", "custom_right_color": "Cor direita", "pro_required": "Funcionalidade licenciada necessária", "api_badge_title": "Endpoint de badge", "api_badge_desc": "Os badges padrão usam predefinições e parâmetros estruturados. Funcionalidades licenciadas podem ativar textos e cores personalizados.", "api_manifest_title": "Manifesto", "api_manifest_desc": "O manifesto JSON-LD pode ser ligado, validado ou integrado em processos de build.", "api_stateless_title": "Sem estado", "api_stateless_desc": "Sem sessões e com respostas determinísticas, adequado a escalabilidade horizontal, proxies inversos e CDN.", "footer_no_legal": "Não constitui aconselhamento jurídico.", "back": "Voltar", "declaration_eyebrow": "Declaração de uso de IA", "fact_component": "Componente", "fact_extent": "Contributo da IA", "fact_activities": "Atividades", "fact_review": "Revisão humana", "fact_note": "Nota", "fact_assurance": "Nível de garantia", "fact_subject": "Conteúdo identificado", "fact_responsibility": "Responsabilidade editorial", "fact_declared_at": "Declarado em", "none_value": "nenhuma", "transparency_label": "Aviso de transparência:", "transparency_text": "Esta declaração descreve o uso indicado de IA. Não é uma licença, certificação ou aconselhamento jurídico.", "manifest": "Manifesto legível por máquina", "bundle": "Pacote de exportação",
|
||||
},
|
||||
"pl": {
|
||||
"meta_description": "Otwarta, maszynowo czytelna i samodzielnie hostowana deklaracja użycia AI.", "nav_generator": "Generator", "nav_background": "Informacje", "standard_eyebrow": "Otwarty standard przejrzystości", "hero_lead": "Widoczna plakietka SVG, zrozumiała strona objaśniająca i maszynowo czytelny JSON-LD, bez plików cookie, śledzenia i obowiązkowej bazy danych.", "generator_eyebrow": "Generator", "generator_title": "Utwórz kod osadzania", "generator_intro": "Wybierz ustawienie lub zbuduj szczegółową deklarację. Podgląd i kod powstają bezpośrednio w przeglądarce.", "field_preset": "Ustawienie", "option_custom": "Niestandardowa struktura", "field_component": "Element", "field_extent": "Udział AI", "field_review": "Weryfikacja człowieka", "field_activities": "Działania", "activities_help": "Standardowe wartości rozdzielone przecinkami, na przykład research, summarisation lub translation.", "field_subject": "URL oznaczonej treści", "field_language": "Język wyjściowy", "field_theme": "Wygląd", "theme_mono": "Monochromatyczny", "theme_color": "Kolorowy", "preview": "Podgląd", "copy_html": "Kopiuj HTML", "copied": "Skopiowano",
|
||||
"pro_eyebrow": "Personalizacja Pro", "pro_title": "Własne teksty i wygląd plakietek", "pro_enabled": "Ta instancja ma ważną licencję Pro. Dostępne są własne teksty i kolory.", "pro_locked": "Własne tytuły, opisy, etykiety i kolory są dostępne w edycji Pro.", "custom_title": "Własny tytuł", "custom_description": "Własny opis", "custom_badge_label": "Lewa etykieta plakietki", "custom_badge_message": "Prawa etykieta plakietki", "custom_left_color": "Lewy kolor", "custom_right_color": "Prawy kolor", "pro_required": "Wymagana licencja Pro", "api_badge_title": "Endpoint plakietki", "api_badge_desc": "Standardowe plakietki korzystają z ustawień i parametrów strukturalnych. Pro dodaje własne teksty i kolory.", "api_manifest_title": "Manifest", "api_manifest_desc": "Manifest JSON-LD można linkować, walidować lub wykorzystywać w procesach budowania.", "api_stateless_title": "Bezstanowy", "api_stateless_desc": "Brak sesji i deterministyczne odpowiedzi, odpowiednie do skalowania poziomego, reverse proxy i CDN.", "footer_no_legal": "To nie jest porada prawna.", "back": "Wstecz", "declaration_eyebrow": "Deklaracja użycia AI", "fact_component": "Element", "fact_extent": "Udział AI", "fact_activities": "Działania", "fact_review": "Weryfikacja człowieka", "fact_note": "Uwaga", "fact_assurance": "Poziom wiarygodności", "fact_subject": "Oznaczona treść", "fact_responsibility": "Odpowiedzialność redakcyjna", "fact_declared_at": "Zadeklarowano", "none_value": "brak", "transparency_label": "Informacja o przejrzystości:", "transparency_text": "Ta deklaracja opisuje wskazane użycie AI. Nie jest licencją, certyfikatem ani poradą prawną.", "manifest": "Manifest maszynowo czytelny",
|
||||
"pro_eyebrow": "Personalizacja licencjonowana", "pro_title": "Własne teksty i wygląd plakietek", "pro_enabled": "Ta instancja ma wymagane licencjonowane funkcje. Dostępne są własne teksty i kolory.", "pro_locked": "Własne tytuły, opisy, etykiety i kolory wymagają odpowiedniej licencjonowanej funkcji.", "custom_title": "Własny tytuł", "custom_description": "Własny opis", "custom_badge_label": "Lewa etykieta plakietki", "custom_badge_message": "Prawa etykieta plakietki", "custom_left_color": "Lewy kolor", "custom_right_color": "Prawy kolor", "pro_required": "Wymagana licencjonowana funkcja", "api_badge_title": "Endpoint plakietki", "api_badge_desc": "Standardowe plakietki korzystają z ustawień i parametrów strukturalnych. Licencjonowane funkcje mogą włączyć własne teksty i kolory.", "api_manifest_title": "Manifest", "api_manifest_desc": "Manifest JSON-LD można linkować, walidować lub wykorzystywać w procesach budowania.", "api_stateless_title": "Bezstanowy", "api_stateless_desc": "Brak sesji i deterministyczne odpowiedzi, odpowiednie do skalowania poziomego, reverse proxy i CDN.", "footer_no_legal": "To nie jest porada prawna.", "back": "Wstecz", "declaration_eyebrow": "Deklaracja użycia AI", "fact_component": "Element", "fact_extent": "Udział AI", "fact_activities": "Działania", "fact_review": "Weryfikacja człowieka", "fact_note": "Uwaga", "fact_assurance": "Poziom wiarygodności", "fact_subject": "Oznaczona treść", "fact_responsibility": "Odpowiedzialność redakcyjna", "fact_declared_at": "Zadeklarowano", "none_value": "brak", "transparency_label": "Informacja o przejrzystości:", "transparency_text": "Ta deklaracja opisuje wskazane użycie AI. Nie jest licencją, certyfikatem ani poradą prawną.", "manifest": "Manifest maszynowo czytelny", "bundle": "Pakiet eksportowy",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -379,6 +356,196 @@ func applyAssuranceTranslations() {
|
||||
}
|
||||
}
|
||||
|
||||
func applyArticle50Translations() {
|
||||
texts := map[string]map[string]string{
|
||||
"de": {
|
||||
"article50_section_title": "Regulatorischer Kontext (EU AI Act, Artikel 50)",
|
||||
"article50_section_intro": "Diese Angaben erfassen Tatsachen, die für Transparenzpflichten nach Artikel 50 relevant sein können. Die daraus abgeleitete Einordnung ist eine technische Entscheidungshilfe und keine Rechtsberatung.",
|
||||
"field_public_interest_text": "Text zu einer Angelegenheit von öffentlichem Interesse",
|
||||
"help_public_interest_text": "Aktivieren, wenn der Text mit dem Zweck veröffentlicht wird, die Öffentlichkeit über eine Angelegenheit von öffentlichem Interesse zu informieren.",
|
||||
"field_deepfake": "Deepfake / realitätsähnliche KI-Manipulation",
|
||||
"help_deepfake": "Aktivieren, wenn Bild, Audio oder Video bestehende Personen, Orte, Objekte oder Ereignisse täuschend echt darstellt oder manipuliert.",
|
||||
"field_artistic_context": "Künstlerischer, kreativer, satirischer oder fiktionaler Kontext",
|
||||
"help_artistic_context": "Dokumentiert einen Kontext, in dem die Offenlegung in geeigneter Weise erfolgen kann, ohne Darstellung oder Genuss unangemessen zu beeinträchtigen.",
|
||||
"field_substantial_review": "Substanzielle menschliche Prüfung oder redaktionelle Kontrolle durchgeführt",
|
||||
"help_substantial_review": "Nur aktivieren, wenn die Kontrolle über bloße Rechtschreib-, Stil- oder Formatkorrekturen hinausgeht.",
|
||||
"field_editorial_responsibility_confirmed": "Redaktionelle Verantwortung wird übernommen",
|
||||
"help_editorial_responsibility_confirmed": "Bestätigt, dass eine natürliche oder juristische Person die redaktionelle Verantwortung für die Veröffentlichung trägt.",
|
||||
"field_responsible_name": "Verantwortliche Person oder Organisation",
|
||||
"field_responsible_url": "URL der verantwortlichen Stelle",
|
||||
"field_first_exposure": "Hinweis ist spätestens bei der ersten Exposition sichtbar",
|
||||
"field_accessibility": "Barrierefreiheit der Kennzeichnung wurde berücksichtigt",
|
||||
"assessment_heading": "Technische Artikel-50-Einordnung",
|
||||
"assessment_disclaimer": "Nicht bindende Entscheidungshilfe. Die tatsächliche rechtliche Bewertung hängt vom konkreten Einsatz und weiteren Rechtsvorschriften ab.",
|
||||
"assessment_not_assessed_title": "Keine regulatorische Einordnung angefordert",
|
||||
"assessment_not_assessed_text": "Die Erklärung dokumentiert die KI-Nutzung, enthält aber keine zusätzlichen Angaben zum regulatorischen Kontext nach Artikel 50.",
|
||||
"assessment_voluntary_title": "Freiwillige Transparenz im Vordergrund",
|
||||
"assessment_voluntary_text": "Auf Grundlage der angegebenen Tatsachen wurde kein typischer Artikel-50-Fall für Deepfakes oder Texte zu Angelegenheiten von öffentlichem Interesse markiert.",
|
||||
"assessment_deepfake_title": "Offenlegung bei Deepfake-Inhalten besonders relevant",
|
||||
"assessment_deepfake_text": "Die Angaben markieren einen Deepfake- oder realitätsähnlich manipulierten Inhalt. Eine klare und unterscheidbare Offenlegung sollte unmittelbar am Inhalt vorgesehen werden.",
|
||||
"assessment_public_text_title": "Offenlegung für Text zu öffentlichem Interesse relevant",
|
||||
"assessment_public_text_text": "Der Text wurde als Information zu einer Angelegenheit von öffentlichem Interesse markiert. Die dokumentierten Voraussetzungen für die Ausnahme aufgrund menschlicher Prüfung und redaktioneller Verantwortung sind nicht vollständig bestätigt.",
|
||||
"assessment_possible_exemption_title": "Mögliche Ausnahme für redaktionell kontrollierten Text",
|
||||
"assessment_possible_exemption_text": "Für den Text zu einer Angelegenheit von öffentlichem Interesse sind substanzielle menschliche Prüfung beziehungsweise redaktionelle Kontrolle und redaktionelle Verantwortung bestätigt. Dies kann für die Ausnahme in Artikel 50 Absatz 4 relevant sein.",
|
||||
"assessment_multiple_title": "Mehrere Artikel-50-Kontexte erkannt",
|
||||
"assessment_multiple_text": "Die Angaben enthalten mehrere regulatorisch relevante Kontexte. Die einzelnen Hinweise sollten gemeinsam betrachtet und für die jeweiligen Inhaltsbestandteile umgesetzt werden.",
|
||||
"warning_first_exposure": "Die Sichtbarkeit spätestens bei der ersten Exposition wurde nicht bestätigt.",
|
||||
"warning_accessibility": "Die Berücksichtigung anwendbarer Barrierefreiheitsanforderungen wurde nicht bestätigt.",
|
||||
"warning_editorial_responsibility": "Eine substanzielle Prüfung ist angegeben, die redaktionelle Verantwortung wurde jedoch nicht bestätigt.",
|
||||
"warning_artistic_context": "Der Inhalt ist als künstlerisch, kreativ, satirisch oder fiktional markiert. Die Offenlegung sollte in einer geeigneten Form erfolgen, die den Werkgenuss nicht unnötig beeinträchtigt.",
|
||||
"badge_article50_deepfake": "KI-manipulierter Inhalt",
|
||||
"badge_article50_text": "KI-generierter Text",
|
||||
"regulatory_context": "Regulatorischer Kontext",
|
||||
"yes_value": "Ja",
|
||||
"no_value": "Nein",
|
||||
},
|
||||
"en": {
|
||||
"article50_section_title": "Regulatory context (EU AI Act, Article 50)",
|
||||
"article50_section_intro": "These fields record facts that may be relevant to Article 50 transparency obligations. The resulting assessment is technical decision support, not legal advice.",
|
||||
"field_public_interest_text": "Text on a matter of public interest",
|
||||
"help_public_interest_text": "Select when the text is published for the purpose of informing the public on a matter of public interest.",
|
||||
"field_deepfake": "Deepfake / realistic AI manipulation",
|
||||
"help_deepfake": "Select when image, audio or video realistically depicts or manipulates existing persons, places, objects or events in a way that may appear authentic.",
|
||||
"field_artistic_context": "Artistic, creative, satirical or fictional context",
|
||||
"help_artistic_context": "Records a context in which disclosure may be provided in an appropriate manner without unduly hampering display or enjoyment.",
|
||||
"field_substantial_review": "Substantial human review or editorial control performed",
|
||||
"help_substantial_review": "Select only where the review goes beyond spelling, style or formatting corrections.",
|
||||
"field_editorial_responsibility_confirmed": "Editorial responsibility is assumed",
|
||||
"help_editorial_responsibility_confirmed": "Confirms that a natural or legal person holds editorial responsibility for publication.",
|
||||
"field_responsible_name": "Responsible person or organisation",
|
||||
"field_responsible_url": "Responsible party URL",
|
||||
"field_first_exposure": "Disclosure is visible no later than first exposure",
|
||||
"field_accessibility": "Accessibility of the disclosure has been considered",
|
||||
"assessment_heading": "Technical Article 50 assessment",
|
||||
"assessment_disclaimer": "Non-binding decision support. The actual legal assessment depends on the concrete use and other applicable law.",
|
||||
"assessment_not_assessed_title": "No regulatory assessment requested",
|
||||
"assessment_not_assessed_text": "The declaration records AI use but does not include additional Article 50 regulatory context.",
|
||||
"assessment_voluntary_title": "Voluntary transparency is the primary use case",
|
||||
"assessment_voluntary_text": "Based on the supplied facts, no typical Article 50 case for deepfakes or public-interest text has been marked.",
|
||||
"assessment_deepfake_title": "Disclosure is particularly relevant for deepfake content",
|
||||
"assessment_deepfake_text": "The content is marked as a deepfake or realistic AI manipulation. A clear and distinguishable disclosure should be provided directly with the content.",
|
||||
"assessment_public_text_title": "Disclosure is relevant for public-interest text",
|
||||
"assessment_public_text_text": "The text is marked as informing the public on a matter of public interest. The documented conditions relating to human review and editorial responsibility are not both confirmed.",
|
||||
"assessment_possible_exemption_title": "Possible exception for editorially controlled text",
|
||||
"assessment_possible_exemption_text": "Substantial human review or editorial control and editorial responsibility are confirmed for the public-interest text. This may be relevant to the exception in Article 50(4).",
|
||||
"assessment_multiple_title": "Multiple Article 50 contexts detected",
|
||||
"assessment_multiple_text": "The supplied facts contain multiple regulatory contexts. Each finding should be considered together and applied to the relevant content components.",
|
||||
"warning_first_exposure": "Visibility no later than first exposure has not been confirmed.",
|
||||
"warning_accessibility": "Consideration of applicable accessibility requirements has not been confirmed.",
|
||||
"warning_editorial_responsibility": "Substantial review is stated, but editorial responsibility has not been confirmed.",
|
||||
"warning_artistic_context": "The content is marked as artistic, creative, satirical or fictional. Disclosure should use an appropriate manner that does not unnecessarily hamper enjoyment of the work.",
|
||||
"badge_article50_deepfake": "AI-manipulated content",
|
||||
"badge_article50_text": "AI-generated text",
|
||||
"regulatory_context": "Regulatory context",
|
||||
"yes_value": "Yes",
|
||||
"no_value": "No",
|
||||
},
|
||||
"fr": {
|
||||
"article50_section_title": "Contexte réglementaire (règlement IA de l’UE, article 50)", "article50_section_intro": "Ces informations consignent des faits pouvant être pertinents au regard des obligations de transparence de l’article 50. L’évaluation est une aide technique et non un conseil juridique.",
|
||||
"field_public_interest_text": "Texte concernant une question d’intérêt public", "help_public_interest_text": "À sélectionner lorsque le texte vise à informer le public sur une question d’intérêt public.",
|
||||
"field_deepfake": "Deepfake / manipulation réaliste par IA", "help_deepfake": "À sélectionner pour une image, un son ou une vidéo réaliste susceptible de paraître authentique.",
|
||||
"field_artistic_context": "Contexte artistique, créatif, satirique ou fictionnel", "help_artistic_context": "Documente un contexte dans lequel l’information peut être adaptée sans nuire indûment à l’œuvre.",
|
||||
"field_substantial_review": "Révision humaine substantielle ou contrôle éditorial effectué", "help_substantial_review": "Uniquement si le contrôle dépasse les corrections de forme, de style ou d’orthographe.",
|
||||
"field_editorial_responsibility_confirmed": "Responsabilité éditoriale assumée", "help_editorial_responsibility_confirmed": "Confirme qu’une personne physique ou morale assume la responsabilité éditoriale.",
|
||||
"field_responsible_name": "Personne ou organisation responsable", "field_responsible_url": "URL du responsable", "field_first_exposure": "Information visible au plus tard lors de la première exposition", "field_accessibility": "Accessibilité de l’information prise en compte",
|
||||
"assessment_heading": "Évaluation technique de l’article 50", "assessment_disclaimer": "Aide à la décision non contraignante; l’analyse juridique dépend du cas concret.",
|
||||
"assessment_not_assessed_title": "Aucune évaluation réglementaire demandée", "assessment_not_assessed_text": "La déclaration documente l’usage de l’IA sans contexte réglementaire supplémentaire.", "assessment_voluntary_title": "Transparence volontaire au premier plan", "assessment_voluntary_text": "Aucun cas typique de deepfake ou de texte d’intérêt public n’a été indiqué.",
|
||||
"assessment_deepfake_title": "Information particulièrement pertinente pour les deepfakes", "assessment_deepfake_text": "Le contenu est indiqué comme deepfake ou manipulation réaliste. Une information claire et distincte devrait accompagner directement le contenu.",
|
||||
"assessment_public_text_title": "Information pertinente pour un texte d’intérêt public", "assessment_public_text_text": "Les conditions documentées relatives au contrôle humain et à la responsabilité éditoriale ne sont pas toutes confirmées.",
|
||||
"assessment_possible_exemption_title": "Exception possible pour un texte sous contrôle éditorial", "assessment_possible_exemption_text": "Un contrôle humain substantiel et une responsabilité éditoriale sont confirmés; cela peut être pertinent pour l’exception de l’article 50, paragraphe 4.", "assessment_multiple_title": "Plusieurs contextes de l’article 50 détectés", "assessment_multiple_text": "Les informations fournies contiennent plusieurs contextes réglementaires. Chaque constat doit être examiné avec les autres et appliqué aux éléments de contenu concernés.",
|
||||
"warning_first_exposure": "La visibilité lors de la première exposition n’est pas confirmée.", "warning_accessibility": "La prise en compte des exigences d’accessibilité n’est pas confirmée.", "warning_editorial_responsibility": "Le contrôle substantiel est indiqué, mais la responsabilité éditoriale n’est pas confirmée.", "warning_artistic_context": "Le contenu est indiqué comme artistique, créatif, satirique ou fictionnel. L’information devrait être présentée d’une manière appropriée qui ne gêne pas inutilement l’appréciation de l’œuvre.",
|
||||
"badge_article50_deepfake": "Contenu manipulé par IA", "badge_article50_text": "Texte généré par IA", "regulatory_context": "Contexte réglementaire", "yes_value": "Oui", "no_value": "Non",
|
||||
},
|
||||
"es": {
|
||||
"article50_section_title": "Contexto normativo (Reglamento de IA de la UE, artículo 50)", "article50_section_intro": "Estos datos recogen hechos que pueden ser relevantes para las obligaciones de transparencia del artículo 50. La evaluación es una ayuda técnica, no asesoramiento jurídico.",
|
||||
"field_public_interest_text": "Texto sobre un asunto de interés público", "help_public_interest_text": "Seleccionar cuando el texto se publica para informar al público sobre un asunto de interés público.",
|
||||
"field_deepfake": "Deepfake / manipulación realista con IA", "help_deepfake": "Seleccionar para imágenes, audio o vídeo realistas que puedan parecer auténticos.",
|
||||
"field_artistic_context": "Contexto artístico, creativo, satírico o ficticio", "help_artistic_context": "Documenta un contexto en el que la divulgación puede adaptarse sin perjudicar indebidamente la obra.",
|
||||
"field_substantial_review": "Revisión humana sustancial o control editorial realizado", "help_substantial_review": "Solo cuando el control vaya más allá de correcciones de ortografía, estilo o formato.",
|
||||
"field_editorial_responsibility_confirmed": "Se asume la responsabilidad editorial", "help_editorial_responsibility_confirmed": "Confirma que una persona física o jurídica asume la responsabilidad editorial.",
|
||||
"field_responsible_name": "Persona u organización responsable", "field_responsible_url": "URL del responsable", "field_first_exposure": "Aviso visible como máximo en la primera exposición", "field_accessibility": "Se ha considerado la accesibilidad del aviso",
|
||||
"assessment_heading": "Evaluación técnica del artículo 50", "assessment_disclaimer": "Ayuda no vinculante; la valoración jurídica depende del caso concreto.",
|
||||
"assessment_not_assessed_title": "No se solicitó evaluación normativa", "assessment_not_assessed_text": "La declaración documenta el uso de IA sin contexto normativo adicional.", "assessment_voluntary_title": "Predomina la transparencia voluntaria", "assessment_voluntary_text": "No se ha marcado un caso típico de deepfake o texto de interés público.",
|
||||
"assessment_deepfake_title": "La divulgación es especialmente relevante para deepfakes", "assessment_deepfake_text": "El contenido está marcado como deepfake o manipulación realista. Debe preverse un aviso claro junto al contenido.",
|
||||
"assessment_public_text_title": "La divulgación es relevante para texto de interés público", "assessment_public_text_text": "No se confirman conjuntamente las condiciones documentadas de revisión humana y responsabilidad editorial.",
|
||||
"assessment_possible_exemption_title": "Posible excepción para texto bajo control editorial", "assessment_possible_exemption_text": "Se confirman revisión humana sustancial y responsabilidad editorial; puede ser relevante para la excepción del artículo 50.4.", "assessment_multiple_title": "Se detectaron varios contextos del artículo 50", "assessment_multiple_text": "Los datos proporcionados contienen varios contextos normativos. Cada resultado debe considerarse conjuntamente y aplicarse a los componentes de contenido correspondientes.",
|
||||
"warning_first_exposure": "No se ha confirmado la visibilidad en la primera exposición.", "warning_accessibility": "No se ha confirmado la consideración de accesibilidad.", "warning_editorial_responsibility": "Se indica revisión sustancial, pero no responsabilidad editorial.", "warning_artistic_context": "El contenido está marcado como artístico, creativo, satírico o ficticio. La divulgación debe presentarse de forma adecuada sin perjudicar innecesariamente el disfrute de la obra.",
|
||||
"badge_article50_deepfake": "Contenido manipulado por IA", "badge_article50_text": "Texto generado por IA", "regulatory_context": "Contexto normativo", "yes_value": "Sí", "no_value": "No",
|
||||
},
|
||||
"it": {
|
||||
"article50_section_title": "Contesto normativo (AI Act UE, articolo 50)", "article50_section_intro": "Questi dati registrano fatti potenzialmente rilevanti per gli obblighi di trasparenza dell’articolo 50. La valutazione è un supporto tecnico e non consulenza legale.",
|
||||
"field_public_interest_text": "Testo su una questione di interesse pubblico", "help_public_interest_text": "Selezionare quando il testo informa il pubblico su una questione di interesse pubblico.",
|
||||
"field_deepfake": "Deepfake / manipolazione realistica con IA", "help_deepfake": "Selezionare per immagini, audio o video realistici che possono apparire autentici.",
|
||||
"field_artistic_context": "Contesto artistico, creativo, satirico o fittizio", "help_artistic_context": "Documenta un contesto in cui l’informativa può essere adattata senza compromettere indebitamente l’opera.",
|
||||
"field_substantial_review": "Revisione umana sostanziale o controllo editoriale effettuato", "help_substantial_review": "Solo se il controllo supera semplici correzioni di ortografia, stile o formato.",
|
||||
"field_editorial_responsibility_confirmed": "Responsabilità editoriale assunta", "help_editorial_responsibility_confirmed": "Conferma che una persona fisica o giuridica assume la responsabilità editoriale.",
|
||||
"field_responsible_name": "Persona o organizzazione responsabile", "field_responsible_url": "URL del responsabile", "field_first_exposure": "Informativa visibile entro la prima esposizione", "field_accessibility": "Accessibilità dell’informativa considerata",
|
||||
"assessment_heading": "Valutazione tecnica dell’articolo 50", "assessment_disclaimer": "Supporto non vincolante; la valutazione giuridica dipende dal caso concreto.",
|
||||
"assessment_not_assessed_title": "Nessuna valutazione normativa richiesta", "assessment_not_assessed_text": "La dichiarazione documenta l’uso dell’IA senza ulteriore contesto normativo.", "assessment_voluntary_title": "Prevale la trasparenza volontaria", "assessment_voluntary_text": "Non è stato indicato un tipico caso di deepfake o testo di interesse pubblico.",
|
||||
"assessment_deepfake_title": "Informativa particolarmente rilevante per i deepfake", "assessment_deepfake_text": "Il contenuto è indicato come deepfake o manipolazione realistica; è opportuno un avviso chiaro direttamente con il contenuto.",
|
||||
"assessment_public_text_title": "Informativa rilevante per testo di interesse pubblico", "assessment_public_text_text": "Non risultano entrambe confermate revisione umana e responsabilità editoriale.",
|
||||
"assessment_possible_exemption_title": "Possibile eccezione per testo con controllo editoriale", "assessment_possible_exemption_text": "Sono confermati controllo umano sostanziale e responsabilità editoriale; ciò può rilevare per l’eccezione dell’articolo 50(4).", "assessment_multiple_title": "Rilevati più contesti dell’articolo 50", "assessment_multiple_text": "Le informazioni fornite contengono più contesti normativi. Ogni risultato dovrebbe essere considerato insieme agli altri e applicato ai relativi componenti di contenuto.",
|
||||
"warning_first_exposure": "La visibilità alla prima esposizione non è confermata.", "warning_accessibility": "La considerazione dell’accessibilità non è confermata.", "warning_editorial_responsibility": "È indicata una revisione sostanziale, ma non la responsabilità editoriale.", "warning_artistic_context": "Il contenuto è indicato come artistico, creativo, satirico o fittizio. L’informativa dovrebbe essere fornita in modo appropriato senza compromettere inutilmente la fruizione dell’opera.",
|
||||
"badge_article50_deepfake": "Contenuto manipolato da IA", "badge_article50_text": "Testo generato da IA", "regulatory_context": "Contesto normativo", "yes_value": "Sì", "no_value": "No",
|
||||
},
|
||||
"nl": {
|
||||
"article50_section_title": "Regelgevingscontext (EU AI Act, artikel 50)", "article50_section_intro": "Deze gegevens leggen feiten vast die relevant kunnen zijn voor de transparantieverplichtingen van artikel 50. De beoordeling is technische beslissingsondersteuning en geen juridisch advies.",
|
||||
"field_public_interest_text": "Tekst over een onderwerp van openbaar belang", "help_public_interest_text": "Selecteer wanneer de tekst het publiek informeert over een onderwerp van openbaar belang.",
|
||||
"field_deepfake": "Deepfake / realistische AI-manipulatie", "help_deepfake": "Selecteer voor realistische beeld-, audio- of video-inhoud die authentiek kan lijken.",
|
||||
"field_artistic_context": "Artistieke, creatieve, satirische of fictieve context", "help_artistic_context": "Legt een context vast waarin de melding passend kan worden vormgegeven zonder het werk onnodig te verstoren.",
|
||||
"field_substantial_review": "Inhoudelijke menselijke beoordeling of redactionele controle uitgevoerd", "help_substantial_review": "Alleen wanneer de controle verder gaat dan spelling, stijl of opmaak.",
|
||||
"field_editorial_responsibility_confirmed": "Redactionele verantwoordelijkheid wordt aanvaard", "help_editorial_responsibility_confirmed": "Bevestigt dat een natuurlijke of rechtspersoon redactionele verantwoordelijkheid draagt.",
|
||||
"field_responsible_name": "Verantwoordelijke persoon of organisatie", "field_responsible_url": "URL verantwoordelijke", "field_first_exposure": "Melding uiterlijk bij eerste blootstelling zichtbaar", "field_accessibility": "Toegankelijkheid van de melding is meegenomen",
|
||||
"assessment_heading": "Technische beoordeling artikel 50", "assessment_disclaimer": "Niet-bindende ondersteuning; de juridische beoordeling hangt af van het concrete geval.",
|
||||
"assessment_not_assessed_title": "Geen regelgevingsbeoordeling gevraagd", "assessment_not_assessed_text": "De verklaring documenteert AI-gebruik zonder aanvullende regelgevingscontext.", "assessment_voluntary_title": "Vrijwillige transparantie staat centraal", "assessment_voluntary_text": "Er is geen typisch deepfake- of openbaar-belangtekstgeval gemarkeerd.",
|
||||
"assessment_deepfake_title": "Melding is bijzonder relevant bij deepfakes", "assessment_deepfake_text": "De inhoud is gemarkeerd als deepfake of realistische manipulatie. Een duidelijke melding hoort direct bij de inhoud.",
|
||||
"assessment_public_text_title": "Melding is relevant voor tekst van openbaar belang", "assessment_public_text_text": "Menselijke beoordeling en redactionele verantwoordelijkheid zijn niet beide bevestigd.",
|
||||
"assessment_possible_exemption_title": "Mogelijke uitzondering voor redactioneel gecontroleerde tekst", "assessment_possible_exemption_text": "Inhoudelijke menselijke beoordeling en redactionele verantwoordelijkheid zijn bevestigd; dit kan relevant zijn voor de uitzondering in artikel 50(4).", "assessment_multiple_title": "Meerdere contexten van artikel 50 gedetecteerd", "assessment_multiple_text": "De opgegeven feiten bevatten meerdere regelgevingscontexten. Elk resultaat moet gezamenlijk worden beoordeeld en op de relevante inhoudsonderdelen worden toegepast.",
|
||||
"warning_first_exposure": "Zichtbaarheid bij de eerste blootstelling is niet bevestigd.", "warning_accessibility": "Aandacht voor toegankelijkheid is niet bevestigd.", "warning_editorial_responsibility": "Inhoudelijke beoordeling is vermeld, maar redactionele verantwoordelijkheid niet.", "warning_artistic_context": "De inhoud is gemarkeerd als artistiek, creatief, satirisch of fictief. De melding hoort op een passende manier te gebeuren zonder het genieten van het werk onnodig te belemmeren.",
|
||||
"badge_article50_deepfake": "AI-gemanipuleerde inhoud", "badge_article50_text": "AI-gegenereerde tekst", "regulatory_context": "Regelgevingscontext", "yes_value": "Ja", "no_value": "Nee",
|
||||
},
|
||||
"pt": {
|
||||
"article50_section_title": "Contexto regulamentar (Regulamento de IA da UE, artigo 50)", "article50_section_intro": "Estes dados registam factos potencialmente relevantes para as obrigações de transparência do artigo 50. A avaliação é apoio técnico e não aconselhamento jurídico.",
|
||||
"field_public_interest_text": "Texto sobre matéria de interesse público", "help_public_interest_text": "Selecionar quando o texto é publicado para informar o público sobre matéria de interesse público.",
|
||||
"field_deepfake": "Deepfake / manipulação realista por IA", "help_deepfake": "Selecionar para imagem, áudio ou vídeo realista que possa parecer autêntico.",
|
||||
"field_artistic_context": "Contexto artístico, criativo, satírico ou ficcional", "help_artistic_context": "Regista um contexto em que a divulgação pode ser adaptada sem prejudicar indevidamente a obra.",
|
||||
"field_substantial_review": "Revisão humana substancial ou controlo editorial realizado", "help_substantial_review": "Apenas quando o controlo vai além de correções de ortografia, estilo ou formatação.",
|
||||
"field_editorial_responsibility_confirmed": "Responsabilidade editorial assumida", "help_editorial_responsibility_confirmed": "Confirma que uma pessoa singular ou coletiva assume responsabilidade editorial.",
|
||||
"field_responsible_name": "Pessoa ou organização responsável", "field_responsible_url": "URL do responsável", "field_first_exposure": "Aviso visível o mais tardar na primeira exposição", "field_accessibility": "A acessibilidade do aviso foi considerada",
|
||||
"assessment_heading": "Avaliação técnica do artigo 50", "assessment_disclaimer": "Apoio não vinculativo; a avaliação jurídica depende do caso concreto.",
|
||||
"assessment_not_assessed_title": "Nenhuma avaliação regulamentar solicitada", "assessment_not_assessed_text": "A declaração documenta o uso de IA sem contexto regulamentar adicional.", "assessment_voluntary_title": "A transparência voluntária é o foco", "assessment_voluntary_text": "Não foi assinalado um caso típico de deepfake ou texto de interesse público.",
|
||||
"assessment_deepfake_title": "A divulgação é particularmente relevante para deepfakes", "assessment_deepfake_text": "O conteúdo está marcado como deepfake ou manipulação realista; deve existir um aviso claro junto do conteúdo.",
|
||||
"assessment_public_text_title": "A divulgação é relevante para texto de interesse público", "assessment_public_text_text": "Revisão humana e responsabilidade editorial não estão ambas confirmadas.",
|
||||
"assessment_possible_exemption_title": "Possível exceção para texto sob controlo editorial", "assessment_possible_exemption_text": "Revisão humana substancial e responsabilidade editorial estão confirmadas; isto pode ser relevante para a exceção do artigo 50(4).", "assessment_multiple_title": "Foram detetados vários contextos do artigo 50", "assessment_multiple_text": "Os dados fornecidos contêm vários contextos regulamentares. Cada conclusão deve ser considerada em conjunto e aplicada aos componentes de conteúdo relevantes.",
|
||||
"warning_first_exposure": "A visibilidade na primeira exposição não foi confirmada.", "warning_accessibility": "A consideração da acessibilidade não foi confirmada.", "warning_editorial_responsibility": "É indicada revisão substancial, mas não responsabilidade editorial.", "warning_artistic_context": "O conteúdo está assinalado como artístico, criativo, satírico ou ficcional. A divulgação deve ser feita de forma adequada sem prejudicar desnecessariamente a fruição da obra.",
|
||||
"badge_article50_deepfake": "Conteúdo manipulado por IA", "badge_article50_text": "Texto gerado por IA", "regulatory_context": "Contexto regulamentar", "yes_value": "Sim", "no_value": "Não",
|
||||
},
|
||||
"pl": {
|
||||
"article50_section_title": "Kontekst regulacyjny (unijny AI Act, art. 50)", "article50_section_intro": "Dane te zapisują fakty, które mogą mieć znaczenie dla obowiązków przejrzystości z art. 50. Ocena stanowi techniczne wsparcie decyzji, a nie poradę prawną.",
|
||||
"field_public_interest_text": "Tekst dotyczący sprawy interesu publicznego", "help_public_interest_text": "Zaznacz, gdy tekst służy informowaniu opinii publicznej o sprawie interesu publicznego.",
|
||||
"field_deepfake": "Deepfake / realistyczna manipulacja AI", "help_deepfake": "Zaznacz dla realistycznego obrazu, dźwięku lub wideo, które może wyglądać na autentyczne.",
|
||||
"field_artistic_context": "Kontekst artystyczny, twórczy, satyryczny lub fikcyjny", "help_artistic_context": "Dokumentuje kontekst, w którym informację można podać odpowiednio bez nadmiernego zakłócania utworu.",
|
||||
"field_substantial_review": "Przeprowadzono istotną kontrolę człowieka lub redakcji", "help_substantial_review": "Tylko gdy kontrola wykracza poza korektę pisowni, stylu lub formatowania.",
|
||||
"field_editorial_responsibility_confirmed": "Przyjęto odpowiedzialność redakcyjną", "help_editorial_responsibility_confirmed": "Potwierdza, że osoba fizyczna lub prawna ponosi odpowiedzialność redakcyjną.",
|
||||
"field_responsible_name": "Odpowiedzialna osoba lub organizacja", "field_responsible_url": "URL podmiotu odpowiedzialnego", "field_first_exposure": "Informacja widoczna najpóźniej przy pierwszym kontakcie", "field_accessibility": "Uwzględniono dostępność informacji",
|
||||
"assessment_heading": "Techniczna ocena art. 50", "assessment_disclaimer": "Niewiążące wsparcie decyzji; ocena prawna zależy od konkretnego przypadku.",
|
||||
"assessment_not_assessed_title": "Nie zażądano oceny regulacyjnej", "assessment_not_assessed_text": "Deklaracja dokumentuje użycie AI bez dodatkowego kontekstu regulacyjnego.", "assessment_voluntary_title": "Głównym celem jest dobrowolna przejrzystość", "assessment_voluntary_text": "Nie oznaczono typowego przypadku deepfake ani tekstu interesu publicznego.",
|
||||
"assessment_deepfake_title": "Ujawnienie jest szczególnie istotne dla deepfake", "assessment_deepfake_text": "Treść oznaczono jako deepfake lub realistyczną manipulację. Jasna informacja powinna towarzyszyć bezpośrednio treści.",
|
||||
"assessment_public_text_title": "Ujawnienie jest istotne dla tekstu interesu publicznego", "assessment_public_text_text": "Nie potwierdzono łącznie kontroli człowieka i odpowiedzialności redakcyjnej.",
|
||||
"assessment_possible_exemption_title": "Możliwy wyjątek dla tekstu pod kontrolą redakcyjną", "assessment_possible_exemption_text": "Potwierdzono istotną kontrolę człowieka i odpowiedzialność redakcyjną; może to mieć znaczenie dla wyjątku z art. 50 ust. 4.", "assessment_multiple_title": "Wykryto wiele kontekstów art. 50", "assessment_multiple_text": "Podane informacje obejmują kilka kontekstów regulacyjnych. Każde ustalenie należy rozpatrywać łącznie i odnosić do właściwych elementów treści.",
|
||||
"warning_first_exposure": "Nie potwierdzono widoczności przy pierwszym kontakcie.", "warning_accessibility": "Nie potwierdzono uwzględnienia dostępności.", "warning_editorial_responsibility": "Wskazano istotną kontrolę, ale nie potwierdzono odpowiedzialności redakcyjnej.", "warning_artistic_context": "Treść oznaczono jako artystyczną, kreatywną, satyryczną lub fikcyjną. Informację należy przekazać w odpowiedni sposób, bez niepotrzebnego utrudniania odbioru utworu.",
|
||||
"badge_article50_deepfake": "Treść zmanipulowana przez AI", "badge_article50_text": "Tekst wygenerowany przez AI", "regulatory_context": "Kontekst regulacyjny", "yes_value": "Tak", "no_value": "Nie",
|
||||
},
|
||||
}
|
||||
for code, values := range texts {
|
||||
l := catalogs[code]
|
||||
for key, value := range values {
|
||||
l.Text[key] = value
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
}
|
||||
|
||||
func cloneLocale(in Locale) Locale {
|
||||
out := in
|
||||
out.Text = cloneMap(in.Text)
|
||||
@@ -431,6 +598,204 @@ func Resolve(explicit, acceptLanguage, fallback string) string {
|
||||
return "en"
|
||||
}
|
||||
|
||||
// applyUCNGBrandCopy aligns the public wording with the UCNG Brand & Identity
|
||||
// Guidelines without changing the declaration schema or API semantics.
|
||||
func applyUCNGBrandCopy() {
|
||||
type brandCopy struct {
|
||||
text map[string]string
|
||||
presets map[string]PresetText
|
||||
extents map[string]string
|
||||
assurances map[string]string
|
||||
}
|
||||
copies := map[string]brandCopy{
|
||||
"de": {
|
||||
text: map[string]string{
|
||||
"meta_description": "UCNG ist ein offenes Notationssystem für transparente, menschen- und maschinenlesbare Content-Deklarationen.",
|
||||
"standard_eyebrow": "UCNG · Universal Content Notation Guidelines",
|
||||
"hero_title": "Declare how content was made.",
|
||||
"hero_lead": "UCNG ist ein offenes Notationssystem für transparente Content-Deklarationen – verständlich für Menschen und maschinenlesbar für Plattformen und Systeme. Diese Anwendung erzeugt Declaration, Badge und JSON-LD aus denselben Angaben.",
|
||||
"generator_eyebrow": "UCNG Declaration",
|
||||
"generator_title": "Content-Deklaration erstellen",
|
||||
"generator_intro": "Wähle eine Vorlage oder beschreibe den Entstehungskontext eines Inhalts strukturiert nach Bestandteilen. Die Declaration dokumentiert Rollen und Prozesse – einschließlich KI-Beteiligung – ohne Qualität oder Wahrheit des Inhalts zu bewerten.",
|
||||
"field_mode": "Art der Declaration", "mode_single": "Einzelner Inhalt", "mode_article": "Mehrteiligen Inhalt deklarieren",
|
||||
"field_preset": "Declaration-Vorlage", "option_custom": "Eigene Struktur", "field_component": "Content-Bestandteil", "field_extent": "UCNG Einordnung", "field_review": "Human Review",
|
||||
"field_activities": "Rolle / Tätigkeit", "activities_help": "Optionale Prozessangaben, zum Beispiel research, summarisation, translation oder generation.",
|
||||
"field_subject": "URL des deklarierten Inhalts", "field_language": "Sprache der Declaration",
|
||||
"declaration_eyebrow": "UCNG Declaration", "fact_component": "Content", "fact_extent": "UCNG Einordnung", "fact_activities": "Rolle / Tätigkeit", "fact_review": "Human Review", "fact_subject": "Deklarierter Inhalt",
|
||||
"fact_assurance": "Declaration Status", "field_assurance": "Declaration Status",
|
||||
"transparency_label": "Zur Einordnung:",
|
||||
"transparency_text": "Diese UCNG Declaration beschreibt den deklarierten Entstehungs- und Transformationsprozess des Inhalts. Sie bewertet weder Wahrheit noch Qualität und begründet für sich allein keine Rechtskonformität, Authentizität, Genauigkeit oder Verifikation.",
|
||||
"manifest": "Maschinenlesbare Declaration", "ai_label": "UCNG",
|
||||
"api_badge_title": "UCNG Badge", "api_badge_desc": "Das Badge ist die kompakte Darstellung einer UCNG Declaration. Es zeigt, dass eine Declaration verfügbar ist; es bedeutet nicht automatisch verified, zertifiziert oder rechtskonform.",
|
||||
"api_manifest_title": "Machine Layer", "api_manifest_desc": "Die strukturierte JSON-LD-Darstellung transportiert dieselben Deklarationsdaten maschinenlesbar für Plattformen, Systeme und Build-Prozesse.",
|
||||
"article_fields": "Content-Bestandteile", "article_help": "Deklariere für jeden Bestandteil, wie er entstanden oder verändert worden ist und welche Rolle KI dabei hatte.",
|
||||
"article_title": "UCNG Content Declaration", "article_summary_heading": "Human-readable Declaration", "article_table_heading": "Declaration Details", "table_component": "Content", "table_extent": "UCNG Einordnung", "table_activities": "Rolle / Tätigkeit", "table_review": "Human Review", "badge_article": "DECLARATION",
|
||||
"component_text": "Text", "component_research": "Recherche", "component_cover_image": "Titelbild", "component_images": "Bilder", "component_translation": "Übersetzung", "component_audio": "Audio", "component_video": "Video", "component_code": "Code",
|
||||
"regulatory_api_label": "Regulatorischer Kontext API",
|
||||
"article50_section_title": "Optionaler regulatorischer Kontext (EU AI Act, Artikel 50)",
|
||||
"article50_section_intro": "Diese optionalen Angaben dokumentieren Tatsachen, die für Transparenzpflichten nach Artikel 50 relevant sein können. Sie sind eine technische Entscheidungshilfe und nicht Teil der UCNG-Kernaussage oder eine Rechtsberatung.",
|
||||
"assessment_heading": "Regulatorische Einordnung", "assessment_disclaimer": "Nicht bindende technische Entscheidungshilfe. Eine UCNG Declaration ist keine Feststellung rechtlicher Compliance.",
|
||||
"assessment_not_assessed_text": "Die UCNG Declaration enthält keinen zusätzlichen regulatorischen Kontext nach Artikel 50.",
|
||||
"badge_article50_deepfake": "AI TRANSFORMED", "badge_article50_text": "AI GENERATED",
|
||||
"bulk_meta_description": "Stapelverarbeitung für strukturierte UCNG Content Declarations.",
|
||||
"bulk_title": "Viele Inhalte. Eine gemeinsame Deklarationssprache.",
|
||||
"bulk_intro": "Erzeuge strukturierte UCNG Declarations für ganze Content-Bestände. Ein gemeinsames Profil kann auf viele URLs angewendet oder als individuelles JSON verarbeitet werden.",
|
||||
"bulk_workspace_intro": "Füge Inhalte zeilenweise ein und definiere ihren gemeinsamen Entstehungskontext einmal. Abweichende Datensätze können im erweiterten JSON-Modus verarbeitet werden.",
|
||||
"bulk_profile_title": "Gemeinsames Declaration-Profil", "bulk_article50_title": "Optionaler regulatorischer Kontext / Artikel 50", "bulk_link_declaration": "UCNG Declaration",
|
||||
},
|
||||
presets: map[string]PresetText{
|
||||
"no-ai": {"HUMAN", "Der wesentliche Inhalt wurde von Menschen erstellt. Normale technische Hilfsmittel und nicht-generative Standardbearbeitung ändern diese Einordnung nicht automatisch."},
|
||||
"research": {"AI ASSISTED", "KI wurde unterstützend für Recherche oder Quellenfindung eingesetzt. Der wesentliche kreative beziehungsweise redaktionelle Inhalt stammt von Menschen und wurde menschlich verantwortet."},
|
||||
"summary": {"AI ASSISTED", "KI wurde unterstützend zur Zusammenfassung von Ausgangsmaterial eingesetzt. Auswahl, Einordnung und Endfassung wurden von Menschen verantwortet."},
|
||||
"full": {"AI GENERATED", "Der wesentliche Inhalt wurde mit einem generativen KI-System erzeugt. Eine dokumentierte menschliche Prüfung wird separat ausgewiesen."},
|
||||
},
|
||||
extents: map[string]string{"none": "HUMAN", "assisted": "AI ASSISTED", "partial": "MIXED · teilweise generiert", "mostly": "AI GENERATED · überwiegend", "full": "AI GENERATED"},
|
||||
assurances: map[string]string{"selfDeclared": "Declared", "technicallyRecorded": "Declared · technisch protokolliert", "signed": "Declared · signiert", "verified": "Verified"},
|
||||
},
|
||||
"en": {
|
||||
text: map[string]string{
|
||||
"meta_description": "UCNG is an open notation framework for transparent, human-readable and machine-readable content declarations.",
|
||||
"standard_eyebrow": "UCNG · Universal Content Notation Guidelines", "hero_title": "Declare how content was made.",
|
||||
"hero_lead": "UCNG is an open notation framework for transparent content declarations – readable by humans and machine-readable for platforms and systems. This application generates the declaration, badge and JSON-LD from the same information.",
|
||||
"generator_eyebrow": "UCNG Declaration", "generator_title": "Create a content declaration", "generator_intro": "Choose a preset or describe the creation context of content by component. The declaration records roles and processes, including AI involvement, without judging the quality or truth of the content.",
|
||||
"field_mode": "Declaration type", "mode_single": "Single content item", "mode_article": "Declare multi-part content", "field_preset": "Declaration preset", "option_custom": "Custom structure", "field_component": "Content component", "field_extent": "UCNG classification", "field_review": "Human review", "field_activities": "Role / activity", "field_subject": "URL of declared content", "field_language": "Declaration language",
|
||||
"declaration_eyebrow": "UCNG Declaration", "fact_component": "Content", "fact_extent": "UCNG classification", "fact_activities": "Role / activity", "fact_review": "Human review", "fact_subject": "Declared content", "fact_assurance": "Declaration status", "field_assurance": "Declaration status",
|
||||
"transparency_label": "About this declaration:", "transparency_text": "This UCNG Declaration describes the declared creation and transformation process of the content. It does not judge truth or quality and does not by itself establish legal compliance, authenticity, accuracy or verification.",
|
||||
"manifest": "Machine-readable declaration", "ai_label": "UCNG",
|
||||
"api_badge_title": "UCNG badge", "api_badge_desc": "The badge is the compact representation of a UCNG Declaration. It indicates that a declaration exists; it does not automatically mean verified, certified or legally compliant.",
|
||||
"api_manifest_title": "Machine layer", "api_manifest_desc": "The structured JSON-LD representation carries the same declaration data in a machine-readable form for platforms, systems and build workflows.",
|
||||
"article_fields": "Content components", "article_help": "For each component, declare how it was created or transformed and what role AI played.", "article_title": "UCNG Content Declaration", "article_summary_heading": "Human-readable declaration", "article_table_heading": "Declaration details", "table_component": "Content", "table_extent": "UCNG classification", "table_activities": "Role / activity", "table_review": "Human review", "badge_article": "DECLARATION",
|
||||
"component_text": "Text", "component_research": "Research", "component_cover_image": "Cover image", "component_images": "Images", "component_translation": "Translation", "component_audio": "Audio", "component_video": "Video", "component_code": "Code", "regulatory_api_label": "Regulatory context API",
|
||||
"article50_section_title": "Optional regulatory context (EU AI Act, Article 50)", "article50_section_intro": "These optional fields record facts that may be relevant to Article 50 transparency obligations. They are technical decision support, not the core UCNG claim and not legal advice.",
|
||||
"assessment_heading": "Regulatory context", "assessment_disclaimer": "Non-binding technical decision support. A UCNG Declaration is not a finding of legal compliance.", "assessment_not_assessed_text": "The UCNG Declaration contains no additional Article 50 regulatory context.", "badge_article50_deepfake": "AI TRANSFORMED", "badge_article50_text": "AI GENERATED",
|
||||
"bulk_meta_description": "Batch processing for structured UCNG content declarations.", "bulk_title": "Many items. One common declaration language.", "bulk_intro": "Generate structured UCNG Declarations for complete content inventories. Apply one shared profile to many URLs or process individual records as JSON.", "bulk_workspace_intro": "Paste content line by line and define its shared creation context once. Records that differ can be processed through advanced JSON mode.", "bulk_profile_title": "Shared declaration profile", "bulk_article50_title": "Optional regulatory context / Article 50", "bulk_link_declaration": "UCNG Declaration",
|
||||
},
|
||||
presets: map[string]PresetText{
|
||||
"no-ai": {"HUMAN", "The substantial content was created by a human. Ordinary technical tools and non-generative standard editing do not automatically change this classification."},
|
||||
"research": {"AI ASSISTED", "AI was used to assist research or source discovery. The substantial creative or editorial content remained human-created and human-responsible."},
|
||||
"summary": {"AI ASSISTED", "AI was used to assist with summarising source material. Selection, contextual assessment and the final version remained under human responsibility."},
|
||||
"full": {"AI GENERATED", "The substantial content was generated by a generative AI system. Any documented human review is stated separately."},
|
||||
},
|
||||
extents: map[string]string{"none": "HUMAN", "assisted": "AI ASSISTED", "partial": "MIXED · partly generated", "mostly": "AI GENERATED · mostly", "full": "AI GENERATED"},
|
||||
assurances: map[string]string{"selfDeclared": "Declared", "technicallyRecorded": "Declared · technically recorded", "signed": "Declared · signed", "verified": "Verified"},
|
||||
},
|
||||
}
|
||||
|
||||
// Localised UI copy. The UCNG class names remain English by design so the
|
||||
// compact labels travel consistently across languages.
|
||||
local := map[string]brandCopy{
|
||||
"fr": {text: map[string]string{"meta_description": "UCNG est un cadre de notation ouvert pour des déclarations de contenu transparentes, lisibles par l’humain et la machine.", "standard_eyebrow": "UCNG · Universal Content Notation Guidelines", "hero_title": "Declare how content was made.", "hero_lead": "UCNG fournit un langage commun pour déclarer comment un contenu numérique a été créé ou transformé, lisible par les personnes et par les machines.", "generator_eyebrow": "UCNG Declaration", "generator_title": "Créer une déclaration de contenu", "generator_intro": "Décrivez le contexte de création par composant, y compris le rôle de l’IA, sans juger la qualité ni la véracité du contenu.", "field_extent": "Classification UCNG", "fact_extent": "Classification UCNG", "table_extent": "Classification UCNG", "field_subject": "URL du contenu déclaré", "fact_subject": "Contenu déclaré", "declaration_eyebrow": "UCNG Declaration", "transparency_label": "À propos de cette déclaration :", "transparency_text": "Cette UCNG Declaration décrit le processus déclaré de création et de transformation du contenu. Elle ne constitue pas à elle seule une vérification, une certification, une preuve d’authenticité, d’exactitude, de vérité ou de conformité juridique.", "ai_label": "UCNG", "article_title": "UCNG Content Declaration", "article_summary_heading": "Déclaration lisible par l’humain", "article_table_heading": "Détails de la déclaration", "badge_article": "DECLARATION", "assessment_heading": "Contexte réglementaire", "component_text": "Texte", "component_research": "Recherche", "component_cover_image": "Image de couverture", "component_images": "Images", "component_translation": "Traduction", "component_audio": "Audio", "component_video": "Vidéo", "component_code": "Code", "regulatory_api_label": "API de contexte réglementaire"}, presets: map[string]PresetText{"no-ai": {"HUMAN", "Le contenu substantiel a été créé par une personne."}, "research": {"AI ASSISTED", "L’IA a été utilisée comme assistance à la recherche; le contenu substantiel reste d’origine humaine."}, "summary": {"AI ASSISTED", "L’IA a assisté la synthèse; la sélection et la version finale restent sous responsabilité humaine."}, "full": {"AI GENERATED", "Le contenu substantiel a été produit par un système d’IA générative; le contrôle humain est indiqué séparément."}}, extents: map[string]string{"none": "HUMAN", "assisted": "AI ASSISTED", "partial": "MIXED · partiellement généré", "mostly": "AI GENERATED · majoritairement", "full": "AI GENERATED"}, assurances: map[string]string{"selfDeclared": "Declared", "technicallyRecorded": "Declared · enregistré techniquement", "signed": "Declared · signé", "verified": "Verified"}},
|
||||
"es": {text: map[string]string{"meta_description": "UCNG es un marco abierto de notación para declaraciones de contenido transparentes, legibles por personas y máquinas.", "standard_eyebrow": "UCNG · Universal Content Notation Guidelines", "hero_title": "Declare how content was made.", "hero_lead": "UCNG ofrece un lenguaje común para declarar cómo se creó o transformó contenido digital, comprensible para personas y legible por máquinas.", "generator_eyebrow": "UCNG Declaration", "generator_title": "Crear una declaración de contenido", "generator_intro": "Describe el contexto de creación por componente, incluido el papel de la IA, sin juzgar la calidad ni la veracidad del contenido.", "field_extent": "Clasificación UCNG", "fact_extent": "Clasificación UCNG", "table_extent": "Clasificación UCNG", "field_subject": "URL del contenido declarado", "fact_subject": "Contenido declarado", "declaration_eyebrow": "UCNG Declaration", "transparency_label": "Sobre esta declaración:", "transparency_text": "Esta UCNG Declaration describe el proceso declarado de creación y transformación del contenido. Por sí sola no establece verificación, certificación, autenticidad, exactitud, veracidad ni cumplimiento legal.", "ai_label": "UCNG", "article_title": "UCNG Content Declaration", "article_summary_heading": "Declaración legible por personas", "article_table_heading": "Detalles de la declaración", "badge_article": "DECLARATION", "assessment_heading": "Contexto regulatorio", "component_text": "Texto", "component_research": "Investigación", "component_cover_image": "Imagen de portada", "component_images": "Imágenes", "component_translation": "Traducción", "component_audio": "Audio", "component_video": "Vídeo", "component_code": "Código", "regulatory_api_label": "API de contexto regulatorio"}, presets: map[string]PresetText{"no-ai": {"HUMAN", "El contenido sustancial fue creado por una persona."}, "research": {"AI ASSISTED", "La IA se utilizó como apoyo a la investigación; el contenido sustancial sigue siendo de origen humano."}, "summary": {"AI ASSISTED", "La IA ayudó a resumir material; la selección y la versión final permanecen bajo responsabilidad humana."}, "full": {"AI GENERATED", "El contenido sustancial fue generado por un sistema de IA generativa; la revisión humana se indica por separado."}}, extents: map[string]string{"none": "HUMAN", "assisted": "AI ASSISTED", "partial": "MIXED · parcialmente generado", "mostly": "AI GENERATED · mayoritariamente", "full": "AI GENERATED"}, assurances: map[string]string{"selfDeclared": "Declared", "technicallyRecorded": "Declared · registrado técnicamente", "signed": "Declared · firmado", "verified": "Verified"}},
|
||||
"it": {text: map[string]string{"meta_description": "UCNG è un framework di notazione aperto per dichiarazioni trasparenti, leggibili da persone e macchine.", "standard_eyebrow": "UCNG · Universal Content Notation Guidelines", "hero_title": "Declare how content was made.", "hero_lead": "UCNG offre un linguaggio comune per dichiarare come i contenuti digitali sono stati creati o trasformati, comprensibile alle persone e leggibile dalle macchine.", "generator_eyebrow": "UCNG Declaration", "generator_title": "Crea una dichiarazione del contenuto", "generator_intro": "Descrivi il contesto di creazione per componente, incluso il ruolo dell’IA, senza giudicare qualità o veridicità del contenuto.", "field_extent": "Classificazione UCNG", "fact_extent": "Classificazione UCNG", "table_extent": "Classificazione UCNG", "field_subject": "URL del contenuto dichiarato", "fact_subject": "Contenuto dichiarato", "declaration_eyebrow": "UCNG Declaration", "transparency_label": "Informazioni sulla dichiarazione:", "transparency_text": "Questa UCNG Declaration descrive il processo dichiarato di creazione e trasformazione del contenuto. Da sola non stabilisce verifica, certificazione, autenticità, accuratezza, veridicità o conformità legale.", "ai_label": "UCNG", "article_title": "UCNG Content Declaration", "article_summary_heading": "Dichiarazione leggibile", "article_table_heading": "Dettagli della dichiarazione", "badge_article": "DECLARATION", "assessment_heading": "Contesto normativo", "component_text": "Testo", "component_research": "Ricerca", "component_cover_image": "Immagine di copertina", "component_images": "Immagini", "component_translation": "Traduzione", "component_audio": "Audio", "component_video": "Video", "component_code": "Codice", "regulatory_api_label": "API del contesto normativo"}, presets: map[string]PresetText{"no-ai": {"HUMAN", "Il contenuto sostanziale è stato creato da una persona."}, "research": {"AI ASSISTED", "L’IA è stata usata come supporto alla ricerca; il contenuto sostanziale resta di origine umana."}, "summary": {"AI ASSISTED", "L’IA ha assistito la sintesi; selezione e versione finale restano sotto responsabilità umana."}, "full": {"AI GENERATED", "Il contenuto sostanziale è stato generato da un sistema di IA generativa; la revisione umana è indicata separatamente."}}, extents: map[string]string{"none": "HUMAN", "assisted": "AI ASSISTED", "partial": "MIXED · parzialmente generato", "mostly": "AI GENERATED · prevalentemente", "full": "AI GENERATED"}, assurances: map[string]string{"selfDeclared": "Declared", "technicallyRecorded": "Declared · registrato tecnicamente", "signed": "Declared · firmato", "verified": "Verified"}},
|
||||
"nl": {text: map[string]string{"meta_description": "UCNG is een open notatiekader voor transparante contentdeclaraties die leesbaar zijn voor mensen en machines.", "standard_eyebrow": "UCNG · Universal Content Notation Guidelines", "hero_title": "Declare how content was made.", "hero_lead": "UCNG biedt een gemeenschappelijke taal om te verklaren hoe digitale inhoud is gemaakt of getransformeerd, leesbaar voor mensen en machines.", "generator_eyebrow": "UCNG Declaration", "generator_title": "Maak een contentdeclaratie", "generator_intro": "Beschrijf de creatiecontext per onderdeel, inclusief de rol van AI, zonder kwaliteit of waarheid van de inhoud te beoordelen.", "field_extent": "UCNG-classificatie", "fact_extent": "UCNG-classificatie", "table_extent": "UCNG-classificatie", "field_subject": "URL van gedeclareerde inhoud", "fact_subject": "Gedeclareerde inhoud", "declaration_eyebrow": "UCNG Declaration", "transparency_label": "Over deze declaratie:", "transparency_text": "Deze UCNG Declaration beschrijft het gedeclareerde creatie- en transformatieproces. Zij stelt op zichzelf geen verificatie, certificering, authenticiteit, nauwkeurigheid, waarheid of juridische naleving vast.", "ai_label": "UCNG", "article_title": "UCNG Content Declaration", "article_summary_heading": "Menselijk leesbare declaratie", "article_table_heading": "Declaratiedetails", "badge_article": "DECLARATION", "assessment_heading": "Regelgevingscontext", "component_text": "Tekst", "component_research": "Onderzoek", "component_cover_image": "Omslagafbeelding", "component_images": "Afbeeldingen", "component_translation": "Vertaling", "component_audio": "Audio", "component_video": "Video", "component_code": "Code", "regulatory_api_label": "API voor regelgevingscontext"}, presets: map[string]PresetText{"no-ai": {"HUMAN", "De wezenlijke inhoud is door een mens gemaakt."}, "research": {"AI ASSISTED", "AI is gebruikt als onderzoeksondersteuning; de wezenlijke inhoud blijft van menselijke oorsprong."}, "summary": {"AI ASSISTED", "AI hielp bij samenvatten; selectie en eindversie blijven onder menselijke verantwoordelijkheid."}, "full": {"AI GENERATED", "De wezenlijke inhoud is gegenereerd door een generatief AI-systeem; menselijke beoordeling wordt afzonderlijk vermeld."}}, extents: map[string]string{"none": "HUMAN", "assisted": "AI ASSISTED", "partial": "MIXED · gedeeltelijk gegenereerd", "mostly": "AI GENERATED · grotendeels", "full": "AI GENERATED"}, assurances: map[string]string{"selfDeclared": "Declared", "technicallyRecorded": "Declared · technisch vastgelegd", "signed": "Declared · ondertekend", "verified": "Verified"}},
|
||||
"pt": {text: map[string]string{"meta_description": "UCNG é um quadro aberto de notação para declarações transparentes, legíveis por pessoas e máquinas.", "standard_eyebrow": "UCNG · Universal Content Notation Guidelines", "hero_title": "Declare how content was made.", "hero_lead": "UCNG oferece uma linguagem comum para declarar como conteúdos digitais foram criados ou transformados, compreensível para pessoas e legível por máquinas.", "generator_eyebrow": "UCNG Declaration", "generator_title": "Criar uma declaração de conteúdo", "generator_intro": "Descreva o contexto de criação por componente, incluindo o papel da IA, sem avaliar a qualidade ou a veracidade do conteúdo.", "field_extent": "Classificação UCNG", "fact_extent": "Classificação UCNG", "table_extent": "Classificação UCNG", "field_subject": "URL do conteúdo declarado", "fact_subject": "Conteúdo declarado", "declaration_eyebrow": "UCNG Declaration", "transparency_label": "Sobre esta declaração:", "transparency_text": "Esta UCNG Declaration descreve o processo declarado de criação e transformação do conteúdo. Por si só, não estabelece verificação, certificação, autenticidade, exatidão, veracidade ou conformidade legal.", "ai_label": "UCNG", "article_title": "UCNG Content Declaration", "article_summary_heading": "Declaração legível por pessoas", "article_table_heading": "Detalhes da declaração", "badge_article": "DECLARATION", "assessment_heading": "Contexto regulamentar", "component_text": "Texto", "component_research": "Pesquisa", "component_cover_image": "Imagem de capa", "component_images": "Imagens", "component_translation": "Tradução", "component_audio": "Áudio", "component_video": "Vídeo", "component_code": "Código", "regulatory_api_label": "API de contexto regulamentar"}, presets: map[string]PresetText{"no-ai": {"HUMAN", "O conteúdo substancial foi criado por uma pessoa."}, "research": {"AI ASSISTED", "A IA foi usada para apoiar a pesquisa; o conteúdo substancial continua a ser de origem humana."}, "summary": {"AI ASSISTED", "A IA ajudou a resumir material; a seleção e a versão final permanecem sob responsabilidade humana."}, "full": {"AI GENERATED", "O conteúdo substancial foi gerado por um sistema de IA generativa; a revisão humana é indicada separadamente."}}, extents: map[string]string{"none": "HUMAN", "assisted": "AI ASSISTED", "partial": "MIXED · parcialmente gerado", "mostly": "AI GENERATED · maioritariamente", "full": "AI GENERATED"}, assurances: map[string]string{"selfDeclared": "Declared", "technicallyRecorded": "Declared · registado tecnicamente", "signed": "Declared · assinado", "verified": "Verified"}},
|
||||
"pl": {text: map[string]string{"meta_description": "UCNG to otwarte ramy notacji dla przejrzystych deklaracji treści czytelnych dla ludzi i maszyn.", "standard_eyebrow": "UCNG · Universal Content Notation Guidelines", "hero_title": "Declare how content was made.", "hero_lead": "UCNG zapewnia wspólny język do deklarowania, jak treści cyfrowe zostały utworzone lub przekształcone, czytelny dla ludzi i maszyn.", "generator_eyebrow": "UCNG Declaration", "generator_title": "Utwórz deklarację treści", "generator_intro": "Opisz kontekst tworzenia według elementów, w tym rolę AI, bez oceniania jakości ani prawdziwości treści.", "field_extent": "Klasyfikacja UCNG", "fact_extent": "Klasyfikacja UCNG", "table_extent": "Klasyfikacja UCNG", "field_subject": "URL zadeklarowanej treści", "fact_subject": "Zadeklarowana treść", "declaration_eyebrow": "UCNG Declaration", "transparency_label": "O tej deklaracji:", "transparency_text": "Ta UCNG Declaration opisuje zadeklarowany proces tworzenia i przekształcania treści. Sama w sobie nie stanowi weryfikacji, certyfikacji, potwierdzenia autentyczności, dokładności, prawdziwości ani zgodności prawnej.", "ai_label": "UCNG", "article_title": "UCNG Content Declaration", "article_summary_heading": "Deklaracja czytelna dla człowieka", "article_table_heading": "Szczegóły deklaracji", "badge_article": "DECLARATION", "assessment_heading": "Kontekst regulacyjny", "component_text": "Tekst", "component_research": "Badania", "component_cover_image": "Obraz okładkowy", "component_images": "Obrazy", "component_translation": "Tłumaczenie", "component_audio": "Audio", "component_video": "Wideo", "component_code": "Kod", "regulatory_api_label": "API kontekstu regulacyjnego"}, presets: map[string]PresetText{"no-ai": {"HUMAN", "Zasadnicza treść została utworzona przez człowieka."}, "research": {"AI ASSISTED", "AI wykorzystano do wsparcia badań; zasadnicza treść pozostaje pochodzenia ludzkiego."}, "summary": {"AI ASSISTED", "AI wspomagała streszczanie; wybór i wersja końcowa pozostają pod odpowiedzialnością człowieka."}, "full": {"AI GENERATED", "Zasadnicza treść została wygenerowana przez generatywny system AI; przegląd człowieka jest wskazywany oddzielnie."}}, extents: map[string]string{"none": "HUMAN", "assisted": "AI ASSISTED", "partial": "MIXED · częściowo wygenerowane", "mostly": "AI GENERATED · w większości", "full": "AI GENERATED"}, assurances: map[string]string{"selfDeclared": "Declared", "technicallyRecorded": "Declared · zapisane technicznie", "signed": "Declared · podpisane", "verified": "Verified"}},
|
||||
}
|
||||
for code, v := range local {
|
||||
copies[code] = v
|
||||
}
|
||||
|
||||
for code, copy := range copies {
|
||||
l := catalogs[code]
|
||||
for key, value := range copy.text {
|
||||
l.Text[key] = value
|
||||
}
|
||||
for key, value := range copy.presets {
|
||||
l.Presets[key] = value
|
||||
}
|
||||
for key, value := range copy.extents {
|
||||
l.Extents[key] = value
|
||||
}
|
||||
for key, value := range copy.assurances {
|
||||
l.Assurances[key] = value
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
|
||||
// Additional status copy used by the human-readable declaration.
|
||||
statusText := map[string]map[string]string{
|
||||
"de": {
|
||||
"assurance_selfDeclared_description": "Declared: Die Angaben wurden von der veröffentlichenden Person oder Organisation deklariert; eine unabhängige Prüfung ist damit nicht verbunden.",
|
||||
"assurance_technicallyRecorded_description": "Declared: Die Angaben werden zusätzlich im Erstellungs- oder Veröffentlichungsprozess technisch protokolliert. Das ist noch keine Verifikation.",
|
||||
"assurance_signed_description": "Declared: Die Declaration ist digital signiert. Eine Signatur kann Herkunft und Unverändertheit der Declaration absichern, nicht automatisch die Richtigkeit des Inhalts.",
|
||||
"assurance_verified_description": "Verified: Bestimmte Angaben der Declaration wurden nach einem dokumentierten technischen oder organisatorischen Prüfverfahren zusätzlich überprüft.",
|
||||
"assurance_selfDeclared_statement": "Status: Declared. Die Angaben wurden von der veröffentlichenden Person oder Organisation bereitgestellt.",
|
||||
"assurance_technicallyRecorded_statement": "Status: Declared; die Angaben sind zusätzlich technisch protokolliert. Daraus folgt keine automatische Verifikation.",
|
||||
"assurance_signed_statement": "Status: Declared; die Declaration ist digital signiert, sodass Herkunft und Unverändertheit der Declaration geprüft werden können.",
|
||||
"assurance_verified_statement": "Status: Verified. Bestimmte Angaben wurden nach einem dokumentierten Prüfverfahren zusätzlich überprüft.",
|
||||
},
|
||||
"en": {
|
||||
"assurance_selfDeclared_description": "Declared: the information was declared by the publishing person or organisation; this does not include independent verification.",
|
||||
"assurance_technicallyRecorded_description": "Declared: the information is also technically recorded during the creation or publication workflow. This is not verification by itself.",
|
||||
"assurance_signed_description": "Declared: the declaration is digitally signed. A signature can protect the declaration's origin and integrity, not automatically the accuracy of the content.",
|
||||
"assurance_verified_description": "Verified: specified declaration data has been additionally checked under a documented technical or organisational procedure.",
|
||||
"assurance_selfDeclared_statement": "Status: Declared. The information was supplied by the publishing person or organisation.",
|
||||
"assurance_technicallyRecorded_statement": "Status: Declared; the information is also technically recorded. This does not automatically constitute verification.",
|
||||
"assurance_signed_statement": "Status: Declared; the declaration is digitally signed so that its origin and integrity can be checked.",
|
||||
"assurance_verified_statement": "Status: Verified. Specified information was additionally checked under a documented procedure.",
|
||||
},
|
||||
}
|
||||
for code, values := range statusText {
|
||||
l := catalogs[code]
|
||||
for key, value := range values {
|
||||
l.Text[key] = value
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
|
||||
localizedExtras := map[string]map[string]string{
|
||||
"fr": {
|
||||
"field_assurance": "Statut de la déclaration", "fact_assurance": "Statut de la déclaration",
|
||||
"bulk_title": "Plusieurs contenus. Un langage de déclaration commun.",
|
||||
"bulk_intro": "Générez des UCNG Declarations structurées pour des ensembles complets de contenus.",
|
||||
"bulk_workspace_intro": "Ajoutez les contenus ligne par ligne et définissez une seule fois leur contexte de création commun.",
|
||||
"bulk_profile_title": "Profil de déclaration commun", "bulk_link_declaration": "UCNG Declaration",
|
||||
},
|
||||
"es": {
|
||||
"field_assurance": "Estado de la declaración", "fact_assurance": "Estado de la declaración",
|
||||
"bulk_title": "Muchos contenidos. Un lenguaje común de declaración.",
|
||||
"bulk_intro": "Genera UCNG Declarations estructuradas para conjuntos completos de contenidos.",
|
||||
"bulk_workspace_intro": "Añade contenidos línea por línea y define una sola vez su contexto común de creación.",
|
||||
"bulk_profile_title": "Perfil común de declaración", "bulk_link_declaration": "UCNG Declaration",
|
||||
},
|
||||
"it": {
|
||||
"field_assurance": "Stato della dichiarazione", "fact_assurance": "Stato della dichiarazione",
|
||||
"bulk_title": "Molti contenuti. Un linguaggio comune di dichiarazione.",
|
||||
"bulk_intro": "Genera UCNG Declarations strutturate per interi insiemi di contenuti.",
|
||||
"bulk_workspace_intro": "Aggiungi i contenuti riga per riga e definisci una sola volta il loro contesto di creazione comune.",
|
||||
"bulk_profile_title": "Profilo di dichiarazione comune", "bulk_link_declaration": "UCNG Declaration",
|
||||
},
|
||||
"nl": {
|
||||
"field_assurance": "Declaratiestatus", "fact_assurance": "Declaratiestatus",
|
||||
"bulk_title": "Veel content. Eén gemeenschappelijke declaratietaal.",
|
||||
"bulk_intro": "Maak gestructureerde UCNG Declarations voor complete contentverzamelingen.",
|
||||
"bulk_workspace_intro": "Voeg content regel voor regel toe en leg de gemeenschappelijke creatiecontext één keer vast.",
|
||||
"bulk_profile_title": "Gemeenschappelijk declaratieprofiel", "bulk_link_declaration": "UCNG Declaration",
|
||||
},
|
||||
"pt": {
|
||||
"field_assurance": "Estado da declaração", "fact_assurance": "Estado da declaração",
|
||||
"bulk_title": "Muitos conteúdos. Uma linguagem comum de declaração.",
|
||||
"bulk_intro": "Gere UCNG Declarations estruturadas para conjuntos completos de conteúdos.",
|
||||
"bulk_workspace_intro": "Adicione conteúdos linha a linha e defina uma vez o contexto de criação comum.",
|
||||
"bulk_profile_title": "Perfil comum de declaração", "bulk_link_declaration": "UCNG Declaration",
|
||||
},
|
||||
"pl": {
|
||||
"field_assurance": "Status deklaracji", "fact_assurance": "Status deklaracji",
|
||||
"bulk_title": "Wiele treści. Jeden wspólny język deklaracji.",
|
||||
"bulk_intro": "Twórz ustrukturyzowane UCNG Declarations dla całych zbiorów treści.",
|
||||
"bulk_workspace_intro": "Dodawaj treści wiersz po wierszu i raz określ ich wspólny kontekst tworzenia.",
|
||||
"bulk_profile_title": "Wspólny profil deklaracji", "bulk_link_declaration": "UCNG Declaration",
|
||||
},
|
||||
}
|
||||
for code, values := range localizedExtras {
|
||||
l := catalogs[code]
|
||||
for key, value := range values {
|
||||
l.Text[key] = value
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
}
|
||||
|
||||
func Get(code string) Locale {
|
||||
if l, ok := catalogs[Normalize(code)]; ok {
|
||||
return cloneLocale(l)
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
FeatureCustomText = "custom_text"
|
||||
FeatureCustomBadge = "custom_badge"
|
||||
FeatureWhiteLabel = "white_label"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Version int `json:"version"`
|
||||
Customer string `json:"customer"`
|
||||
Plan string `json:"plan"`
|
||||
Features []string `json:"features"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Edition string `json:"edition"`
|
||||
Licensed bool `json:"licensed"`
|
||||
Customer string `json:"customer,omitempty"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Features []string `json:"features"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
status Status
|
||||
features map[string]bool
|
||||
}
|
||||
|
||||
func Community() *Manager {
|
||||
return &Manager{status: Status{Edition: "community", Features: []string{}}, features: map[string]bool{}}
|
||||
}
|
||||
|
||||
func New(publicKeyEncoded, token, baseURL string, allowInsecurePro bool, now time.Time) *Manager {
|
||||
if allowInsecurePro {
|
||||
features := []string{FeatureCustomBadge, FeatureCustomText, FeatureWhiteLabel}
|
||||
return &Manager{
|
||||
status: Status{Edition: "pro", Licensed: true, Customer: "development", Plan: "pro-dev", Features: features, Reason: "insecure development override"},
|
||||
features: featureSet(features),
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(publicKeyEncoded) == "" || strings.TrimSpace(token) == "" {
|
||||
return Community()
|
||||
}
|
||||
claims, err := Verify(publicKeyEncoded, token, now)
|
||||
if err != nil {
|
||||
m := Community()
|
||||
m.status.Reason = err.Error()
|
||||
return m
|
||||
}
|
||||
if err := validateDomain(claims.Domains, baseURL); err != nil {
|
||||
m := Community()
|
||||
m.status.Reason = err.Error()
|
||||
return m
|
||||
}
|
||||
features := uniqueSorted(claims.Features)
|
||||
return &Manager{
|
||||
status: Status{
|
||||
Edition: "pro", Licensed: true, Customer: claims.Customer, Plan: claims.Plan,
|
||||
Features: features, ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339),
|
||||
},
|
||||
features: featureSet(features),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Has(feature string) bool { return m != nil && m.features[feature] }
|
||||
|
||||
func (m *Manager) Status() Status {
|
||||
if m == nil {
|
||||
return Community().status
|
||||
}
|
||||
out := m.status
|
||||
out.Features = make([]string, len(m.status.Features))
|
||||
copy(out.Features, m.status.Features)
|
||||
return out
|
||||
}
|
||||
|
||||
func Sign(privateKey ed25519.PrivateKey, claims Claims) (string, error) {
|
||||
if len(privateKey) != ed25519.PrivateKeySize {
|
||||
return "", errors.New("invalid Ed25519 private key")
|
||||
}
|
||||
if err := validateClaims(claims, time.Unix(claims.IssuedAt, 0)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal claims: %w", err)
|
||||
}
|
||||
payloadPart := base64.RawURLEncoding.EncodeToString(payload)
|
||||
sig := ed25519.Sign(privateKey, []byte(payloadPart))
|
||||
return payloadPart + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
func Verify(publicKeyEncoded, token string, now time.Time) (Claims, error) {
|
||||
publicKeyBytes, err := decodeKey(publicKeyEncoded)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("decode public key: %w", err)
|
||||
}
|
||||
if len(publicKeyBytes) != ed25519.PublicKeySize {
|
||||
return Claims{}, errors.New("public key must be an Ed25519 public key")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return Claims{}, errors.New("license token has invalid format")
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return Claims{}, errors.New("license signature is not valid base64url")
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(publicKeyBytes), []byte(parts[0]), sig) {
|
||||
return Claims{}, errors.New("license signature verification failed")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, errors.New("license payload is not valid base64url")
|
||||
}
|
||||
var claims Claims
|
||||
dec := json.NewDecoder(strings.NewReader(string(payload)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&claims); err != nil {
|
||||
return Claims{}, fmt.Errorf("decode license payload: %w", err)
|
||||
}
|
||||
if err := validateClaims(claims, now); err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func validateClaims(c Claims, now time.Time) error {
|
||||
if c.Version != 1 {
|
||||
return errors.New("unsupported license version")
|
||||
}
|
||||
if strings.TrimSpace(c.Customer) == "" {
|
||||
return errors.New("license customer is required")
|
||||
}
|
||||
if strings.TrimSpace(c.Plan) == "" {
|
||||
return errors.New("license plan is required")
|
||||
}
|
||||
if c.IssuedAt <= 0 || c.ExpiresAt <= 0 || c.ExpiresAt <= c.IssuedAt {
|
||||
return errors.New("license timestamps are invalid")
|
||||
}
|
||||
if now.Unix() < c.IssuedAt-300 {
|
||||
return errors.New("license is not active yet")
|
||||
}
|
||||
if now.Unix() >= c.ExpiresAt {
|
||||
return errors.New("license has expired")
|
||||
}
|
||||
allowed := map[string]bool{FeatureCustomText: true, FeatureCustomBadge: true, FeatureWhiteLabel: true}
|
||||
for _, feature := range c.Features {
|
||||
if !allowed[feature] {
|
||||
return fmt.Errorf("unknown license feature %q", feature)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDomain(domains []string, baseURL string) error {
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return errors.New("BASE_URL has no valid host for licensed domain validation")
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
for _, allowed := range domains {
|
||||
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||
if allowed == "*" {
|
||||
return nil
|
||||
}
|
||||
if host == allowed {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
suffix := strings.TrimPrefix(allowed, "*")
|
||||
if strings.HasSuffix(host, suffix) && host != strings.TrimPrefix(suffix, ".") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("host %q is not covered by the license", host)
|
||||
}
|
||||
|
||||
func DecodePrivateKey(encoded string) (ed25519.PrivateKey, error) {
|
||||
b, err := decodeKey(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) == ed25519.SeedSize {
|
||||
return ed25519.NewKeyFromSeed(b), nil
|
||||
}
|
||||
if len(b) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("private key must contain an Ed25519 seed or private key")
|
||||
}
|
||||
return ed25519.PrivateKey(b), nil
|
||||
}
|
||||
|
||||
func EncodeKey(key []byte) string { return base64.RawURLEncoding.EncodeToString(key) }
|
||||
|
||||
func decodeKey(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("key is not valid base64")
|
||||
}
|
||||
|
||||
func featureSet(features []string) map[string]bool {
|
||||
out := make(map[string]bool, len(features))
|
||||
for _, f := range features {
|
||||
out[f] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueSorted(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" && !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignedLicense(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
claims := Claims{Version: 1, Customer: "Example", Plan: "pro", Features: []string{FeatureCustomText}, Domains: []string{"*.example.org"}, IssuedAt: now.Add(-time.Hour).Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix()}
|
||||
token, err := Sign(priv, claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := New(EncodeKey(pub), token, "https://ai.example.org", false, now)
|
||||
if !m.Has(FeatureCustomText) || m.Status().Edition != "pro" {
|
||||
t.Fatalf("unexpected status: %#v", m.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredLicenseFallsBackToCommunity(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
claims := Claims{Version: 1, Customer: "Example", Plan: "pro", Features: []string{FeatureCustomBadge}, IssuedAt: now.Add(-2 * time.Hour).Unix(), ExpiresAt: now.Add(-time.Hour).Unix()}
|
||||
// Sign validates relative to issuedAt, so an already-expired token can still be created for the verification test.
|
||||
token, err := Sign(priv, claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := New(EncodeKey(pub), token, "https://example.org", false, now)
|
||||
if m.Status().Edition != "community" || m.Has(FeatureCustomBadge) {
|
||||
t.Fatalf("unexpected status: %#v", m.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalDomainWildcardAllowsAnyHost(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
claims := Claims{
|
||||
Version: 1, Customer: "Global", Plan: "pro",
|
||||
Features: []string{FeatureCustomBadge}, Domains: []string{"*"},
|
||||
IssuedAt: now.Add(-time.Hour).Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix(),
|
||||
}
|
||||
token, err := Sign(priv, claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, baseURL := range []string{"https://example.org", "https://ai.customer.test", "http://localhost:8080"} {
|
||||
m := New(EncodeKey(pub), token, baseURL, false, now)
|
||||
if !m.Has(FeatureCustomBadge) || m.Status().Edition != "pro" {
|
||||
t.Fatalf("global wildcard did not allow %s: %#v", baseURL, m.Status())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,65 +43,44 @@ type FAQ struct {
|
||||
Answer string
|
||||
}
|
||||
|
||||
type Prices struct {
|
||||
Community string
|
||||
Pro string
|
||||
Publisher string
|
||||
Agency string
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
MetaDescription string
|
||||
GeneratorURL string
|
||||
SalesURL string
|
||||
NavFeatures string
|
||||
NavPricing string
|
||||
NavInstall string
|
||||
NavGenerator string
|
||||
NavBackground string
|
||||
LanguageLabel string
|
||||
HeroEyebrow string
|
||||
HeroTitle string
|
||||
HeroLead string
|
||||
PrimaryCTA string
|
||||
SecondaryCTA string
|
||||
Proof []string
|
||||
FeaturesEyebrow string
|
||||
FeaturesTitle string
|
||||
FeaturesLead string
|
||||
Features []Feature
|
||||
CompareEyebrow string
|
||||
CompareTitle string
|
||||
CompareLead string
|
||||
CompareFeature string
|
||||
CompareCommunity string
|
||||
ComparePro string
|
||||
Comparison []ComparisonRow
|
||||
PricingEyebrow string
|
||||
PricingTitle string
|
||||
PricingLead string
|
||||
PricePeriod string
|
||||
PriceNote string
|
||||
Plans []Plan
|
||||
InstallEyebrow string
|
||||
InstallTitle string
|
||||
InstallLead string
|
||||
InstallMethods []InstallMethod
|
||||
CopyLabel string
|
||||
CopiedLabel string
|
||||
ConfigTitle string
|
||||
ConfigVariable string
|
||||
ConfigDefault string
|
||||
ConfigMeaning string
|
||||
Config []ConfigRow
|
||||
FAQEyebrow string
|
||||
FAQTitle string
|
||||
FAQs []FAQ
|
||||
FinalTitle string
|
||||
FinalLead string
|
||||
FinalPrimary string
|
||||
FinalSecondary string
|
||||
Footer string
|
||||
MetaDescription string
|
||||
GeneratorURL string
|
||||
NavFeatures string
|
||||
NavPricing string
|
||||
NavInstall string
|
||||
NavGenerator string
|
||||
NavBackground string
|
||||
LanguageLabel string
|
||||
HeroEyebrow string
|
||||
HeroTitle string
|
||||
HeroLead string
|
||||
PrimaryCTA string
|
||||
SecondaryCTA string
|
||||
Proof []string
|
||||
FeaturesEyebrow string
|
||||
FeaturesTitle string
|
||||
FeaturesLead string
|
||||
Features []Feature
|
||||
InstallEyebrow string
|
||||
InstallTitle string
|
||||
InstallLead string
|
||||
InstallMethods []InstallMethod
|
||||
CopyLabel string
|
||||
CopiedLabel string
|
||||
ConfigTitle string
|
||||
ConfigVariable string
|
||||
ConfigDefault string
|
||||
ConfigMeaning string
|
||||
Config []ConfigRow
|
||||
FAQEyebrow string
|
||||
FAQTitle string
|
||||
FAQs []FAQ
|
||||
FinalTitle string
|
||||
FinalLead string
|
||||
FinalPrimary string
|
||||
FinalSecondary string
|
||||
Footer string
|
||||
}
|
||||
|
||||
type copySet struct {
|
||||
@@ -128,7 +107,7 @@ type copySet struct {
|
||||
EnterprisePrice string
|
||||
}
|
||||
|
||||
func Build(lang, productName, baseURL, salesURL, contactURL string, prices Prices) Page {
|
||||
func Build(lang, productName, baseURL, contactURL string) Page {
|
||||
lang = normalize(lang)
|
||||
copy := copies()[lang]
|
||||
if copy.HeroTitle == "" {
|
||||
@@ -137,90 +116,56 @@ func Build(lang, productName, baseURL, salesURL, contactURL string, prices Price
|
||||
}
|
||||
copy = neutralize(copy, lang)
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
if salesURL == "" {
|
||||
salesURL = contactURL
|
||||
}
|
||||
if salesURL == "" {
|
||||
salesURL = baseURL + "/?lang=" + lang
|
||||
}
|
||||
if contactURL == "" {
|
||||
contactURL = baseURL + "/?lang=" + lang
|
||||
}
|
||||
if prices.Community == "" {
|
||||
prices.Community = "0 €"
|
||||
}
|
||||
if prices.Pro == "" {
|
||||
prices.Pro = "19 €"
|
||||
}
|
||||
if prices.Publisher == "" {
|
||||
prices.Publisher = "79 €"
|
||||
}
|
||||
if prices.Agency == "" {
|
||||
prices.Agency = "199 €"
|
||||
}
|
||||
|
||||
generatorURL := baseURL + "/?lang=" + lang + "#generator"
|
||||
planNames := []string{"Community", "Pro", "Publisher", "Agency", "Enterprise"}
|
||||
planPrices := []string{prices.Community, prices.Pro, prices.Publisher, prices.Agency, copy.EnterprisePrice}
|
||||
plans := make([]Plan, 0, len(planNames))
|
||||
for i := range planNames {
|
||||
url := salesURL
|
||||
if i == 0 {
|
||||
url = generatorURL
|
||||
}
|
||||
period := copy.PricePeriod
|
||||
if i == 4 {
|
||||
period = ""
|
||||
}
|
||||
plans = append(plans, Plan{
|
||||
Name: planNames[i], Price: planPrices[i], Period: period,
|
||||
Description: copy.PlanDescriptions[i], Features: copy.PlanFeatures[i], CTA: copy.PlanCTA[i], URL: url, Featured: i == 1,
|
||||
})
|
||||
}
|
||||
|
||||
install := []InstallMethod{
|
||||
{ID: "compose", Title: copy.InstallTitles[0], Summary: copy.InstallSummaries[0], Code: `cp .env.example .env
|
||||
# Adjust BASE_URL and PUBLIC_NAME in .env
|
||||
docker compose up -d --build
|
||||
curl -fsS http://localhost:8080/readyz`},
|
||||
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.6.3 .
|
||||
{ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:2.0.2 .
|
||||
docker run -d --name ai-disclosure \
|
||||
-p 8080:8080 \
|
||||
-e BASE_URL=https://ai.example.org \
|
||||
-e PUBLIC_NAME="AI Usage Disclosure" \
|
||||
-e PUBLIC_NAME="UCNG" \
|
||||
--read-only --tmpfs /tmp \
|
||||
ai-disclosure-standard:1.6.3`},
|
||||
{ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, Domain und TLS-Secret in deploy/kubernetes.yaml ersetzen
|
||||
kubectl create secret generic ai-disclosure-license \
|
||||
--from-literal=token='...'
|
||||
ai-disclosure-standard:2.0.2`},
|
||||
{ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, domain and TLS secret in deploy/kubernetes.yaml ersetzen
|
||||
kubectl apply -f deploy/kubernetes.yaml
|
||||
kubectl rollout status deployment/ai-disclosure`},
|
||||
kubectl rollout status deployment/ai-disclosure
|
||||
|
||||
# Licensed bulk service (optional)
|
||||
kubectl apply -f deploy/kubernetes-bulk.yaml`},
|
||||
{ID: "swarm", Title: copy.InstallTitles[3], Summary: copy.InstallSummaries[3], Code: `docker swarm init
|
||||
export LICENSE_TOKEN='...'
|
||||
export LICENSE_MODE='offline'
|
||||
docker stack deploy -c deploy/swarm-stack.yaml ai-disclosure`},
|
||||
docker stack deploy -c deploy/swarm-stack.yaml ai-disclosure
|
||||
# Optional licensed bulk deployment: deploy/swarm-bulk-stack.yaml`},
|
||||
{ID: "go", Title: copy.InstallTitles[4], Summary: copy.InstallSummaries[4], Code: `go test ./...
|
||||
go build -trimpath -o bin/server ./cmd/server
|
||||
BASE_URL=http://localhost:8080 \
|
||||
PUBLIC_NAME="AI Usage Disclosure" \
|
||||
PUBLIC_NAME="UCNG" \
|
||||
./bin/server`},
|
||||
}
|
||||
|
||||
configNames := []string{"LISTEN_ADDRESS", "BASE_URL", "PUBLIC_NAME", "CONTACT_URL", "DEFAULT_LANGUAGE", "LICENSE_TOKEN", "LICENSE_MODE", "LICENSE_SERVER_URL"}
|
||||
configDefaults := []string{":8080", "http://localhost:8080", productName, contactURL, "de", "-", "offline", "-"}
|
||||
configNames := []string{"LISTEN_ADDRESS", "BASE_URL", "PUBLIC_NAME", "CONTACT_URL", "DEFAULT_LANGUAGE", "SERVICE_MODE", "LICENSE_TOKEN", "LICENSE_MODE", "LICENSE_SERVER_URL", "API_ALLOWED_ORIGIN"}
|
||||
configDefaults := []string{":8080", "http://localhost:8080", productName, contactURL, "de", "full", "–", "offline", "–", "*"}
|
||||
configDescriptions := configurationDescriptions(lang)
|
||||
configRows := make([]ConfigRow, 0, len(configNames))
|
||||
for i := range configNames {
|
||||
configRows = append(configRows, ConfigRow{Name: configNames[i], Default: configDefaults[i], Description: configDescriptions[i]})
|
||||
description := ""
|
||||
if i < len(configDescriptions) {
|
||||
description = configDescriptions[i]
|
||||
}
|
||||
configRows = append(configRows, ConfigRow{Name: configNames[i], Default: configDefaults[i], Description: description})
|
||||
}
|
||||
|
||||
return Page{
|
||||
MetaDescription: copy.MetaDescription, GeneratorURL: generatorURL, SalesURL: salesURL,
|
||||
NavFeatures: copy.NavFeatures, NavPricing: copy.NavPricing, NavInstall: copy.NavInstall, NavGenerator: copy.NavGenerator, NavBackground: copy.NavBackground, LanguageLabel: copy.LanguageLabel,
|
||||
MetaDescription: copy.MetaDescription, GeneratorURL: generatorURL,
|
||||
NavFeatures: copy.NavFeatures, NavInstall: copy.NavInstall, NavGenerator: copy.NavGenerator, NavBackground: copy.NavBackground, LanguageLabel: copy.LanguageLabel,
|
||||
HeroEyebrow: copy.HeroEyebrow, HeroTitle: copy.HeroTitle, HeroLead: copy.HeroLead, PrimaryCTA: copy.PrimaryCTA, SecondaryCTA: copy.SecondaryCTA, Proof: copy.Proof,
|
||||
FeaturesEyebrow: copy.FeaturesEyebrow, FeaturesTitle: copy.FeaturesTitle, FeaturesLead: copy.FeaturesLead, Features: copy.Features,
|
||||
CompareEyebrow: copy.CompareEyebrow, CompareTitle: copy.CompareTitle, CompareLead: copy.CompareLead, CompareFeature: copy.CompareFeature, CompareCommunity: copy.CompareCommunity, ComparePro: copy.ComparePro, Comparison: copy.Comparison,
|
||||
PricingEyebrow: copy.PricingEyebrow, PricingTitle: copy.PricingTitle, PricingLead: copy.PricingLead, PricePeriod: copy.PricePeriod, PriceNote: copy.PriceNote, Plans: plans,
|
||||
InstallEyebrow: copy.InstallEyebrow, InstallTitle: copy.InstallTitle, InstallLead: copy.InstallLead, InstallMethods: install, CopyLabel: copy.CopyLabel, CopiedLabel: copy.CopiedLabel,
|
||||
ConfigTitle: copy.ConfigTitle, ConfigVariable: copy.ConfigVariable, ConfigDefault: copy.ConfigDefault, ConfigMeaning: copy.ConfigMeaning, Config: configRows,
|
||||
FAQEyebrow: copy.FAQEyebrow, FAQTitle: copy.FAQTitle, FAQs: copy.FAQs,
|
||||
@@ -228,16 +173,16 @@ PUBLIC_NAME="AI Usage Disclosure" \
|
||||
}
|
||||
}
|
||||
|
||||
func configurationDescriptions(lang string) [8]string {
|
||||
values := map[string][8]string{
|
||||
"de": {"Bind-Adresse des HTTP-Servers.", "Öffentliche Basis-URL ohne abschließenden Slash.", "Produkt- oder Seitentitel.", "Optionale externe Kontakt- oder Projektseite.", "Rückfallsprache der Ausgabe.", "Vom Anbieter signierter Lizenz-Token.", "Mindestmodus: offline, hybrid oder online.", "Optionaler zentraler Prüfserver für Hybrid- und Online-Modus."},
|
||||
"en": {"HTTP server bind address.", "Public base URL without a trailing slash.", "Product or site title.", "Optional external contact or project page.", "Fallback output language.", "Vendor-signed licence token.", "Minimum mode: offline, hybrid or online.", "Optional central verification server for hybrid and online mode."},
|
||||
"fr": {"Adresse d'écoute du serveur HTTP.", "URL publique sans barre oblique finale.", "Nom du produit ou du site.", "Page externe facultative de contact ou de projet.", "Langue de secours.", "Jeton de licence signé par l'éditeur.", "Mode minimal : offline, hybrid ou online.", "Serveur central facultatif pour les modes hybride et en ligne."},
|
||||
"es": {"Dirección de escucha del servidor HTTP.", "URL pública sin barra final.", "Nombre del producto o sitio.", "Página externa opcional de contacto o del proyecto.", "Idioma de reserva.", "Token de licencia firmado por el proveedor.", "Modo mínimo: offline, hybrid u online.", "Servidor central opcional para los modos híbrido y en línea."},
|
||||
"it": {"Indirizzo di ascolto del server HTTP.", "URL pubblica senza slash finale.", "Nome del prodotto o sito.", "Pagina esterna facoltativa di contatto o del progetto.", "Lingua di fallback.", "Token di licenza firmato dal fornitore.", "Modalità minima: offline, hybrid o online.", "Server centrale opzionale per modalità ibrida e online."},
|
||||
"nl": {"Luisteradres van de HTTP-server.", "Publieke basis-URL zonder afsluitende slash.", "Product- of sitenaam.", "Optionele externe contact- of projectpagina.", "Terugvaltaal.", "Door de leverancier ondertekend licentietoken.", "Minimale modus: offline, hybrid of online.", "Optionele centrale controleserver voor hybride en online modus."},
|
||||
"pt": {"Endereço de escuta do servidor HTTP.", "URL pública sem barra final.", "Nome do produto ou site.", "Página externa opcional de contacto ou do projeto.", "Idioma de fallback.", "Token de licença assinado pelo fornecedor.", "Modo mínimo: offline, hybrid ou online.", "Servidor central opcional para os modos híbrido e online."},
|
||||
"pl": {"Adres nasłuchiwania serwera HTTP.", "Publiczny bazowy URL bez końcowego ukośnika.", "Nazwa produktu lub witryny.", "Opcjonalna zewnętrzna strona kontaktowa lub projektu.", "Język zapasowy.", "Token licencji podpisany przez dostawcę.", "Minimalny tryb: offline, hybrid lub online.", "Opcjonalny centralny serwer weryfikacji dla trybu hybrydowego i online."},
|
||||
func configurationDescriptions(lang string) []string {
|
||||
values := map[string][]string{
|
||||
"de": {"Bind-Adresse des HTTP-Servers.", "Öffentliche Basis-URL ohne abschließenden Slash.", "Produkt- oder Seitentitel.", "Optionale externe Kontakt- oder Projektseite.", "Rückfallsprache der Ausgabe.", "Betriebsmodus: full, api oder bulk.", "Vom Anbieter signierter Lizenz-Token.", "Mindestmodus: offline, hybrid oder online.", "Optionaler zentraler Prüfserver für Hybrid- und Online-Modus.", "Erlaubter CORS-Origin für API-Endpunkte; leer deaktiviert CORS."},
|
||||
"en": {"HTTP server bind address.", "Public base URL without a trailing slash.", "Product or site title.", "Optional external contact or project page.", "Fallback output language.", "Runtime mode: full, api or bulk.", "Vendor-signed licence token.", "Minimum mode: offline, hybrid or online.", "Optional central verification server for hybrid and online mode.", "Allowed CORS origin for API endpoints; empty disables CORS."},
|
||||
"fr": {"Adresse d’écoute du serveur HTTP.", "URL publique sans barre oblique finale.", "Nom du produit ou du site.", "Page externe facultative de contact ou de projet.", "Langue de secours.", "Mode d’exécution : full, api ou bulk.", "Jeton de licence signé par l’éditeur.", "Mode minimal : offline, hybrid ou online.", "Serveur central facultatif pour les modes hybride et en ligne.", "Origine CORS autorisée pour l’API ; vide désactive CORS."},
|
||||
"es": {"Dirección de escucha del servidor HTTP.", "URL pública sin barra final.", "Nombre del producto o sitio.", "Página externa opcional de contacto o del proyecto.", "Idioma de reserva.", "Modo de ejecución: full, api o bulk.", "Token de licencia firmado por el proveedor.", "Modo mínimo: offline, hybrid u online.", "Servidor central opcional para los modos híbrido y en línea.", "Origen CORS permitido para la API; vacío desactiva CORS."},
|
||||
"it": {"Indirizzo di ascolto del server HTTP.", "URL pubblica senza slash finale.", "Nome del prodotto o sito.", "Pagina esterna facoltativa di contatto o del progetto.", "Lingua di fallback.", "Modalità di esecuzione: full, api o bulk.", "Token di licenza firmato dal fornitore.", "Modalità minima: offline, hybrid o online.", "Server centrale opzionale per modalità ibrida e online.", "Origine CORS consentita per l’API; vuoto disabilita CORS."},
|
||||
"nl": {"Luisteradres van de HTTP-server.", "Publieke basis-URL zonder afsluitende slash.", "Product- of sitenaam.", "Optionele externe contact- of projectpagina.", "Terugvaltaal.", "Uitvoermodus: full, api of bulk.", "Door de leverancier ondertekend licentietoken.", "Minimale modus: offline, hybrid of online.", "Optionele centrale controleserver voor hybride en online modus.", "Toegestane CORS-origin voor API-endpoints; leeg schakelt CORS uit."},
|
||||
"pt": {"Endereço de escuta do servidor HTTP.", "URL pública sem barra final.", "Nome do produto ou site.", "Página externa opcional de contacto ou do projeto.", "Idioma de fallback.", "Modo de execução: full, api ou bulk.", "Token de licença assinado pelo fornecedor.", "Modo mínimo: offline, hybrid ou online.", "Servidor central opcional para os modos híbrido e online.", "Origem CORS permitida para a API; vazio desativa CORS."},
|
||||
"pl": {"Adres nasłuchiwania serwera HTTP.", "Publiczny bazowy URL bez końcowego ukośnika.", "Nazwa produktu lub witryny.", "Opcjonalna zewnętrzna strona kontaktowa lub projektu.", "Język zapasowy.", "Tryb pracy: full, api lub bulk.", "Token licencji podpisany przez dostawcę.", "Minimalny tryb: offline, hybrid lub online.", "Opcjonalny centralny serwer weryfikacji dla trybu hybrydowego i online.", "Dozwolony origin CORS dla API; pusty wyłącza CORS."},
|
||||
}
|
||||
if descriptions, ok := values[normalize(lang)]; ok {
|
||||
return descriptions
|
||||
@@ -260,9 +205,9 @@ func neutralize(c copySet, lang string) copySet {
|
||||
case "de":
|
||||
c.MetaDescription = "Funktionen und Installation des offenen Standards für KI-Nutzungserklärungen."
|
||||
c.HeroEyebrow = "Open Source · Selbst hostbar"
|
||||
c.HeroLead = "Ein zustandsloser Go-Dienst für SVG-Badges, verständliche Erklärungseiten und JSON-LD - internationalisiert und für hochverfügbare Deployments ausgelegt."
|
||||
c.HeroLead = "Ein zustandsloser Go-Dienst für SVG-Badges, verständliche Erklärungseiten, JSON-LD und nicht bindende Artikel-50-Entscheidungsunterstützung – internationalisiert und für hochverfügbare Deployments ausgelegt."
|
||||
c.FeaturesLead = "Sichtbare Hinweise, verständliche Zusammenfassungen und maschinenlesbare Deklarationen in einer schlanken Anwendung."
|
||||
c.InstallLead = "Alle Varianten verwenden dasselbe Go-Binary und können ohne externe Dienste betrieben werden."
|
||||
c.InstallLead = "Die offene Anwendung kann vollständig selbst gehostet werden; für lizenzierte Betriebsfunktionen stehen API-, Bulk- und Hybrid-Verifikationsmodi bereit."
|
||||
c.FinalTitle = "Direkt mit einer eigenen Erklärung starten."
|
||||
c.FinalLead = "Der Generator funktioniert ohne Registrierung, Cookies oder externe Ressourcen."
|
||||
case "fr":
|
||||
@@ -291,11 +236,81 @@ func neutralize(c copySet, lang string) copySet {
|
||||
c.InstallLead = "Wszystkie warianty korzystają z tego samego pliku binarnego Go i działają bez usług zewnętrznych."
|
||||
default:
|
||||
c.HeroEyebrow = "Open source · Self-hostable"
|
||||
c.FeaturesLead = "Visible notices, readable summaries and machine-readable declarations in one lightweight application."
|
||||
c.FeaturesLead = "Visible notices, readable summaries, machine-readable declarations and non-binding Article 50 decision support in one lightweight application."
|
||||
c.InstallLead = "Every deployment uses the same Go binary and can run without external services."
|
||||
c.FinalTitle = "Start with your own declaration."
|
||||
c.FinalLead = "The generator works without registration, cookies or external resources."
|
||||
}
|
||||
return applyUCNGPositioning(c, lang)
|
||||
}
|
||||
|
||||
func applyUCNGPositioning(c copySet, lang string) copySet {
|
||||
type localCopy struct {
|
||||
meta, eyebrow, title, lead, primary, featuresEyebrow, featuresTitle, featuresLead, finalTitle, finalLead, finalPrimary, footer string
|
||||
proof []string
|
||||
features []Feature
|
||||
faqs []FAQ
|
||||
}
|
||||
copies := map[string]localCopy{
|
||||
"de": {
|
||||
meta: "UCNG – offenes Notationssystem für transparente, menschen- und maschinenlesbare Content-Deklarationen.",
|
||||
eyebrow: "UCNG · Open by design", title: "Declare how content was made.",
|
||||
lead: "UCNG schafft eine gemeinsame Sprache dafür, wie digitale Inhalte entstanden oder verändert worden sind. Die Referenzimplementierung verbindet eine verständliche Declaration mit maschinenlesbaren Daten – plattform-, anbieter- und medienunabhängig.",
|
||||
primary: "Declaration erstellen", proof: []string{"Human-readable", "Machine-readable", "Vendor-neutral", "Self-hostable"},
|
||||
featuresEyebrow: "Content transparency", featuresTitle: "Eine Declaration. Zwei Zielgruppen.", featuresLead: "UCNG beschreibt Herkunft und Transformation. Es bewertet weder Wahrheit noch Qualität eines Inhalts.",
|
||||
features: []Feature{
|
||||
{"01", "Content needs context.", "Digitale Inhalte entstehen nicht mehr auf nur eine Weise. UCNG macht den deklarierten Entstehungskontext transportierbar."},
|
||||
{"02", "Human-readable", "Kompakte Labels und verständliche Declaration-Seiten erklären den angegebenen Erstellungs- und Transformationsprozess."},
|
||||
{"03", "Machine-readable", "Dieselben Angaben stehen strukturiert als JSON-LD zur Verfügung und können von Plattformen, Systemen und Build-Prozessen verarbeitet werden."},
|
||||
{"04", "Simple labels. Detailed metadata.", "HUMAN, AI ASSISTED, MIXED und AI GENERATED geben eine kompakte Einordnung; Detailangaben dokumentieren Tätigkeit, Human Review und Declaration Status."},
|
||||
{"Open", "Platform-independent", "Die Anwendung ist selbst hostbar und bindet die Declaration nicht an einen bestimmten Publisher, KI-Anbieter oder eine Plattform."},
|
||||
{"Neutral", "Declaration ≠ Verification", "Ein UCNG Badge zeigt, dass eine Declaration vorhanden ist. Es bedeutet nicht automatisch verified, zertifiziert, wahr oder rechtskonform."},
|
||||
{"Interoperabel", "Ein Format, mehrere Medien", "Text, Bild, Audio, Video, Code und mehr können in derselben Deklarationslogik beschrieben werden."},
|
||||
},
|
||||
faqs: []FAQ{
|
||||
{"Bewertet UCNG, ob ein Inhalt wahr oder falsch ist?", "Nein. UCNG beschreibt den deklarierten Entstehungs- und Transformationsprozess. Ein UCNG Badge bedeutet nicht automatisch verified, zertifiziert, wahr oder rechtskonform."},
|
||||
{"Ist UCNG ein offizielles System der Europäischen Union?", "Nein. UCNG ist als unabhängiges offenes Framework positioniert. Regulatorischer Kontext kann dokumentiert werden, ist aber nicht mit einer UCNG Declaration gleichzusetzen."},
|
||||
},
|
||||
finalTitle: "Content deserves context.", finalLead: "Erstelle eine UCNG Declaration und mache den angegebenen Entstehungskontext für Menschen und Maschinen lesbar.", finalPrimary: "Declaration erstellen", footer: "UCNG · Universal Content Notation Guidelines · An open notation framework for transparent content declarations.",
|
||||
},
|
||||
"en": {
|
||||
meta: "UCNG – an open notation framework for transparent, human-readable and machine-readable content declarations.",
|
||||
eyebrow: "UCNG · Open by design", title: "Declare how content was made.",
|
||||
lead: "UCNG provides a common language for describing how digital content was created or transformed. The reference implementation combines a human-readable declaration with machine-readable data – independent of platform, vendor and medium.",
|
||||
primary: "Create a declaration", proof: []string{"Human-readable", "Machine-readable", "Vendor-neutral", "Self-hostable"},
|
||||
featuresEyebrow: "Content transparency", featuresTitle: "One declaration. Two audiences.", featuresLead: "UCNG describes origin and transformation. It does not judge the truth or quality of content.",
|
||||
features: []Feature{
|
||||
{"01", "Content needs context.", "Digital content is no longer created in one way. UCNG makes the declared creation context portable."},
|
||||
{"02", "Human-readable", "Compact labels and readable declaration pages explain the stated creation and transformation process."},
|
||||
{"03", "Machine-readable", "The same information is available as structured JSON-LD for platforms, systems and build workflows."},
|
||||
{"04", "Simple labels. Detailed metadata.", "HUMAN, AI ASSISTED, MIXED and AI GENERATED provide a compact classification; details record activity, human review and declaration status."},
|
||||
{"Open", "Platform-independent", "The application is self-hostable and does not bind a declaration to a particular publisher, AI vendor or platform."},
|
||||
{"Neutral", "Declaration ≠ Verification", "A UCNG badge indicates that a declaration exists. It does not automatically mean verified, certified, true or legally compliant."},
|
||||
{"Interoperable", "One model, multiple media", "Text, image, audio, video, code and more can be described using the same declaration logic."},
|
||||
},
|
||||
faqs: []FAQ{
|
||||
{"Does UCNG decide whether content is true or false?", "No. UCNG describes the declared creation and transformation process. A UCNG badge does not automatically mean verified, certified, true or legally compliant."},
|
||||
{"Is UCNG an official European Union system?", "No. UCNG is positioned as an independent open framework. Regulatory context can be documented, but it is not the same as a UCNG Declaration."},
|
||||
},
|
||||
finalTitle: "Content deserves context.", finalLead: "Create a UCNG Declaration and make the stated creation context readable to people and machines.", finalPrimary: "Create a declaration", footer: "UCNG · Universal Content Notation Guidelines · An open notation framework for transparent content declarations.",
|
||||
},
|
||||
}
|
||||
if v, ok := copies[lang]; ok {
|
||||
c.MetaDescription, c.HeroEyebrow, c.HeroTitle, c.HeroLead, c.PrimaryCTA = v.meta, v.eyebrow, v.title, v.lead, v.primary
|
||||
c.Proof = v.proof
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead, c.Features = v.featuresEyebrow, v.featuresTitle, v.featuresLead, v.features
|
||||
c.FAQEyebrow, c.FAQTitle, c.FAQs = "FAQ", map[string]string{"de": "Häufige Fragen", "en": "Common questions"}[lang], v.faqs
|
||||
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.Footer = v.finalTitle, v.finalLead, v.finalPrimary, v.footer
|
||||
return c
|
||||
}
|
||||
|
||||
// Other supported UI languages retain their translated technical content,
|
||||
// while the brand-level claim and terminology stay consistent.
|
||||
c.HeroTitle = "Declare how content was made."
|
||||
c.HeroEyebrow = "UCNG · Open by design"
|
||||
c.PrimaryCTA = map[string]string{"fr": "Créer une déclaration", "es": "Crear una declaración", "it": "Crea una dichiarazione", "nl": "Maak een declaratie", "pt": "Criar uma declaração", "pl": "Utwórz deklarację"}[lang]
|
||||
c.FinalPrimary = c.PrimaryCTA
|
||||
c.Footer = "UCNG · Universal Content Notation Guidelines · An open notation framework for transparent content declarations."
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -321,7 +336,7 @@ func commonComparison(lang, yes, no, included, proOnly string) []ComparisonRow {
|
||||
"fr": {"Badges SVG", "Pages de déclaration HTML", "JSON-LD et validation de schéma", "8 langues", "Docker, Kubernetes et Swarm", "Textes de déclaration personnalisés", "Libellés et couleurs personnalisés", "Licence hors ligne signée", "Activation liée au domaine", "Support commercial"},
|
||||
"es": {"Insignias SVG", "Páginas de declaración HTML", "JSON-LD y validación de esquema", "8 idiomas", "Docker, Kubernetes y Swarm", "Textos de declaración propios", "Etiquetas y colores propios", "Licencia offline firmada", "Activación ligada al dominio", "Soporte comercial"},
|
||||
"it": {"Badge SVG", "Pagine di dichiarazione HTML", "JSON-LD e validazione schema", "8 lingue", "Docker, Kubernetes e Swarm", "Testi di dichiarazione personalizzati", "Etichette e colori personalizzati", "Licenza offline firmata", "Attivazione legata al dominio", "Supporto commerciale"},
|
||||
"nl": {"SVG-badges", "HTML-verklaringspagina's", "JSON-LD en schemavalidatie", "8 talen", "Docker, Kubernetes en Swarm", "Eigen verklaringsteksten", "Eigen labels en kleuren", "Ondertekende offline licentie", "Domeingebonden activatie", "Commerciële support"},
|
||||
"nl": {"SVG-badges", "HTML-verklaringspagina’s", "JSON-LD en schemavalidatie", "8 talen", "Docker, Kubernetes en Swarm", "Eigen verklaringsteksten", "Eigen labels en kleuren", "Ondertekende offline licentie", "Domeingebonden activatie", "Commerciële support"},
|
||||
"pt": {"Badges SVG", "Páginas de declaração HTML", "JSON-LD e validação de esquema", "8 idiomas", "Docker, Kubernetes e Swarm", "Textos de declaração próprios", "Etiquetas e cores próprias", "Licença offline assinada", "Ativação ligada ao domínio", "Suporte comercial"},
|
||||
"pl": {"Plakietki SVG", "Strony deklaracji HTML", "JSON-LD i walidacja schematu", "8 języków", "Docker, Kubernetes i Swarm", "Własne teksty deklaracji", "Własne etykiety i kolory", "Podpisana licencja offline", "Aktywacja związana z domeną", "Wsparcie komercyjne"},
|
||||
}
|
||||
@@ -342,7 +357,7 @@ func german() copySet {
|
||||
return copySet{
|
||||
MetaDescription: "Produkt, Preise und Installation für den offenen KI-Nutzungsstandard.",
|
||||
NavFeatures: "Features", NavPricing: "Preise", NavInstall: "Installation", NavGenerator: "Generator", NavBackground: "Hintergrund", LanguageLabel: "Sprache",
|
||||
HeroEyebrow: "Open Source im Kern · Pro bei Bedarf", HeroTitle: "KI-Nutzung transparent kennzeichnen - ohne Plattformzwang.",
|
||||
HeroEyebrow: "Open Source im Kern · Pro bei Bedarf", HeroTitle: "KI-Nutzung transparent kennzeichnen – ohne Plattformzwang.",
|
||||
HeroLead: "Ein zustandsloser Go-Dienst für SVG-Badges, verständliche Erklärungseiten und JSON-LD. Selbst hostbar, internationalisiert und für hochverfügbare Deployments gebaut.",
|
||||
PrimaryCTA: "Badge erstellen", SecondaryCTA: "Pro anfragen", Proof: []string{"8 Sprachen", "Keine Cookies", "Keine Datenbank", "Docker & Kubernetes"},
|
||||
FeaturesEyebrow: "Funktionsumfang", FeaturesTitle: "Vom sichtbaren Hinweis bis zum maschinenlesbaren Nachweis.", FeaturesLead: "Die Community-Ausgabe deckt die offene Integration ab. Pro ergänzt individuelle Darstellung und kommerzielle Lizenzierung.",
|
||||
@@ -351,13 +366,13 @@ func german() copySet {
|
||||
{"Verständlich", "Erklärungseiten", "Menschenlesbare Seiten erläutern KI-Anteil, Tätigkeit, Prüfung und Verantwortung."},
|
||||
{"Maschinenlesbar", "JSON-LD und Schema", "Manifeste lassen sich verlinken, validieren und in Build- oder CMS-Prozesse übernehmen."},
|
||||
{"International", "Acht Sprachen", "Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Portugiesisch und Polnisch."},
|
||||
{"Pro", "Eigene Texte und Designs", "Eigene Titel, Beschreibungen, Badge-Texte und Farben - serverseitig geschützt."},
|
||||
{"Pro", "Eigene Texte und Designs", "Eigene Titel, Beschreibungen, Badge-Texte und Farben – serverseitig geschützt."},
|
||||
{"Offline", "Signierte Lizenz", "Ed25519-Lizenzen werden lokal geprüft; ein externer Lizenzserver ist nicht erforderlich."},
|
||||
{"Skalierbar", "High Availability", "Zustandslose Replikate, Readiness-Checks, HPA, Swarm-Replikation und CDN-freundliches Caching."},
|
||||
{"Datensparsam", "Privacy by default", "Keine Cookies, keine Sessions, keine externen Assets und kein verpflichtendes Tracking."},
|
||||
},
|
||||
CompareEyebrow: "Editionen", CompareTitle: "Offener Standard oder individuelle Markenintegration.", CompareLead: "Alle Kernformate bleiben frei nutzbar. Pro schaltet ausschließlich die kommerzielle Anpassung frei.", CompareFeature: "Funktion", CompareCommunity: "Community", ComparePro: "Pro",
|
||||
Comparison: commonComparison("de", "Enthalten", "-", "Enthalten", "Je nach Tarif"),
|
||||
Comparison: commonComparison("de", "Enthalten", "–", "Enthalten", "Je nach Tarif"),
|
||||
PricingEyebrow: "Preise", PricingTitle: "Einfach nach Einsatzbreite skalieren.", PricingLead: "Die Software bleibt selbst hostbar. Bezahlte Tarife lizenzieren Custom-Funktionen, Domains und Support.", PricePeriod: "/ Monat", PriceNote: "Einführungspreise bei jährlicher Abrechnung, zuzüglich gesetzlicher Steuern. Enterprise-Angebote werden individuell vereinbart.",
|
||||
PlanDescriptions: [5]string{"Für Open-Source-Nutzung und Standardkennzeichnungen.", "Für einzelne professionelle Websites mit eigener Darstellung.", "Für Publisher mit mehreren Marken oder Portalen.", "Für Agenturen und wiederkehrende Kundendeployments.", "Für individuelle Vertrags-, SLA- und Deployment-Anforderungen."},
|
||||
PlanFeatures: [5][]string{
|
||||
@@ -401,7 +416,7 @@ func english() copySet {
|
||||
{"Scalable", "High availability", "Stateless replicas, readiness checks, HPA, Swarm replicas and CDN-friendly caching."},
|
||||
{"Privacy-first", "No tracking required", "No cookies, sessions, external assets or mandatory analytics."},
|
||||
},
|
||||
CompareEyebrow: "Editions", CompareTitle: "Open standard or branded integration.", CompareLead: "All core formats remain free. Pro only unlocks commercial customisation.", CompareFeature: "Feature", CompareCommunity: "Community", ComparePro: "Pro", Comparison: commonComparison("en", "Included", "-", "Included", "By plan"),
|
||||
CompareEyebrow: "Editions", CompareTitle: "Open standard or branded integration.", CompareLead: "All core formats remain free. Pro only unlocks commercial customisation.", CompareFeature: "Feature", CompareCommunity: "Community", ComparePro: "Pro", Comparison: commonComparison("en", "Included", "–", "Included", "By plan"),
|
||||
PricingEyebrow: "Pricing", PricingTitle: "Scale with the breadth of your deployment.", PricingLead: "The software remains self-hostable. Paid plans license custom capabilities, domains and support.", PricePeriod: "/ month", PriceNote: "Introductory prices with annual billing, excluding applicable taxes. Enterprise terms are agreed individually.",
|
||||
PlanDescriptions: [5]string{"For open-source use and standard declarations.", "For individual professional sites with custom presentation.", "For publishers operating several brands or portals.", "For agencies deploying repeatedly for clients.", "For custom contract, SLA and deployment requirements."},
|
||||
PlanFeatures: [5][]string{{"Standard presets", "All 8 languages", "SVG, HTML and JSON-LD", "Self-hosting"}, {"Up to 3 domains", "Custom declaration copy", "Custom badge labels and colours", "Signed offline licence"}, {"Up to 20 domains", "All Pro capabilities", "Priority support", "Migration assistance"}, {"Up to 100 domains", "Client domains", "Commercial agency use", "Technical onboarding"}, {"Custom domain count", "SLA and support windows", "Private distribution", "Custom licensing terms"}},
|
||||
@@ -416,32 +431,32 @@ func english() copySet {
|
||||
|
||||
func french() copySet {
|
||||
c := english()
|
||||
c.MetaDescription = "Produit, tarifs et installation de la norme ouverte de déclaration d'utilisation de l'IA."
|
||||
c.MetaDescription = "Produit, tarifs et installation de la norme ouverte de déclaration d’utilisation de l’IA."
|
||||
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Fonctions", "Tarifs", "Installation", "Générateur", "Contexte", "Langue"
|
||||
c.HeroEyebrow, c.HeroTitle = "Cœur open source · Pro si nécessaire", "Déclarez l’usage de l’IA sans dépendre d’une plateforme."
|
||||
c.HeroLead = "Un service Go sans état pour badges SVG, pages explicatives et JSON-LD. Auto-hébergeable, internationalisé et conçu pour la haute disponibilité."
|
||||
c.PrimaryCTA, c.SecondaryCTA = "Créer un badge", "Contacter Pro"
|
||||
c.Proof = []string{"8 langues", "Sans cookies", "Sans base de données", "Docker & Kubernetes"}
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Fonctions", "Du signal visible à la déclaration lisible par machine.", "Community couvre l'intégration ouverte. Pro ajoute la personnalisation et la licence commerciale."
|
||||
c.Features = []Feature{{"Visible", "Badges SVG", "Badges déterministes et mis en cache à partir de modèles ou de paramètres structurés."}, {"Compréhensible", "Pages de déclaration", "Elles expliquent la contribution de l'IA, les activités, la vérification et la responsabilité."}, {"Lisible par machine", "JSON-LD et schéma", "Manifeste utilisable dans les workflows de build et de CMS."}, {"International", "Huit langues", "Allemand, anglais, français, espagnol, italien, néerlandais, portugais et polonais."}, {"Pro", "Textes et design personnalisés", "Titres, descriptions, libellés et couleurs protégés côté serveur."}, {"Hors ligne", "Licence signée", "Vérification locale Ed25519 sans serveur de licence externe."}, {"Évolutif", "Haute disponibilité", "Répliques sans état, probes, HPA, Swarm et cache CDN."}, {"Respectueux", "Sans suivi obligatoire", "Sans cookies, sessions, ressources externes ni analytics obligatoires."}}
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Fonctions", "Du signal visible à la déclaration lisible par machine.", "Community couvre l’intégration ouverte. Pro ajoute la personnalisation et la licence commerciale."
|
||||
c.Features = []Feature{{"Visible", "Badges SVG", "Badges déterministes et mis en cache à partir de modèles ou de paramètres structurés."}, {"Compréhensible", "Pages de déclaration", "Elles expliquent la contribution de l’IA, les activités, la vérification et la responsabilité."}, {"Lisible par machine", "JSON-LD et schéma", "Manifeste utilisable dans les workflows de build et de CMS."}, {"International", "Huit langues", "Allemand, anglais, français, espagnol, italien, néerlandais, portugais et polonais."}, {"Pro", "Textes et design personnalisés", "Titres, descriptions, libellés et couleurs protégés côté serveur."}, {"Hors ligne", "Licence signée", "Vérification locale Ed25519 sans serveur de licence externe."}, {"Évolutif", "Haute disponibilité", "Répliques sans état, probes, HPA, Swarm et cache CDN."}, {"Respectueux", "Sans suivi obligatoire", "Sans cookies, sessions, ressources externes ni analytics obligatoires."}}
|
||||
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Éditions", "Norme ouverte ou intégration à votre marque.", "Les formats essentiels restent gratuits. Pro déverrouille uniquement la personnalisation commerciale."
|
||||
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Fonction", "Community", "Pro"
|
||||
c.Comparison = commonComparison("fr", "Inclus", "-", "Inclus", "Selon l'offre")
|
||||
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Tarifs", "Adaptez le prix à l'étendue du déploiement.", "Le logiciel reste auto-hébergeable. Les offres payantes couvrent les personnalisations, domaines et support."
|
||||
c.Comparison = commonComparison("fr", "Inclus", "–", "Inclus", "Selon l’offre")
|
||||
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Tarifs", "Adaptez le prix à l’étendue du déploiement.", "Le logiciel reste auto-hébergeable. Les offres payantes couvrent les personnalisations, domaines et support."
|
||||
c.PricePeriod, c.PriceNote = "/ mois", "Tarifs de lancement avec facturation annuelle, hors taxes applicables. Conditions Enterprise sur devis."
|
||||
c.PlanDescriptions = [5]string{"Pour l'open source et les déclarations standard.", "Pour quelques sites professionnels personnalisés.", "Pour les éditeurs avec plusieurs marques ou portails.", "Pour les agences et les déploiements clients répétés.", "Pour les exigences contractuelles, SLA et déploiements spécifiques."}
|
||||
c.PlanFeatures = [5][]string{{"Modèles standard", "8 langues", "SVG, HTML et JSON-LD", "Auto-hébergement"}, {"Jusqu'à 3 domaines", "Textes personnalisés", "Libellés et couleurs personnalisés", "Licence hors ligne signée"}, {"Jusqu'à 20 domaines", "Toutes les fonctions Pro", "Support prioritaire", "Aide à la migration"}, {"Jusqu'à 100 domaines", "Domaines clients", "Usage commercial agence", "Onboarding technique"}, {"Nombre de domaines sur mesure", "SLA et fenêtres de support", "Distribution privée", "Conditions sur mesure"}}
|
||||
c.PlanDescriptions = [5]string{"Pour l’open source et les déclarations standard.", "Pour quelques sites professionnels personnalisés.", "Pour les éditeurs avec plusieurs marques ou portails.", "Pour les agences et les déploiements clients répétés.", "Pour les exigences contractuelles, SLA et déploiements spécifiques."}
|
||||
c.PlanFeatures = [5][]string{{"Modèles standard", "8 langues", "SVG, HTML et JSON-LD", "Auto-hébergement"}, {"Jusqu’à 3 domaines", "Textes personnalisés", "Libellés et couleurs personnalisés", "Licence hors ligne signée"}, {"Jusqu’à 20 domaines", "Toutes les fonctions Pro", "Support prioritaire", "Aide à la migration"}, {"Jusqu’à 100 domaines", "Domaines clients", "Usage commercial agence", "Onboarding technique"}, {"Nombre de domaines sur mesure", "SLA et fenêtres de support", "Distribution privée", "Conditions sur mesure"}}
|
||||
c.PlanCTA = [5]string{"Commencer gratuitement", "Contacter Pro", "Contacter Publisher", "Contacter Agency", "Contacter les ventes"}
|
||||
c.EnterprisePrice = "Sur devis"
|
||||
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Déploiement", "Prêt pour la production en quelques commandes.", "Toutes les options exécutent le même binaire Go. Community et Pro ne diffèrent que par les secrets de licence facultatifs."
|
||||
c.CopyLabel, c.CopiedLabel = "Copier", "Copié"
|
||||
c.InstallSummaries = [5]string{"Configuration déclarative locale ou mono-serveur.", "Construire l'image et lancer directement un conteneur renforcé.", "Trois répliques, probes, rolling updates, HPA et PodDisruptionBudget.", "Plusieurs répliques avec l'orchestrateur natif Docker.", "Pour le développement, les tests ou un service systemd natif."}
|
||||
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variables d'environnement importantes", "Variable", "Valeur par défaut", "Rôle"
|
||||
c.ConfigDescriptions = [8]string{"Adresse d'écoute HTTP.", "URL publique sans barre oblique finale.", "Nom du produit ou du site.", "Page externe facultative de contact ou de projet.", "Destination des demandes Pro et commerciales.", "Langue de repli.", "Clé publique Ed25519 pour Pro.", "Jeton Pro signé et éventuellement lié au domaine."}
|
||||
c.InstallSummaries = [5]string{"Configuration déclarative locale ou mono-serveur.", "Construire l’image et lancer directement un conteneur renforcé.", "Trois répliques, probes, rolling updates, HPA et PodDisruptionBudget.", "Plusieurs répliques avec l’orchestrateur natif Docker.", "Pour le développement, les tests ou un service systemd natif."}
|
||||
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variables d’environnement importantes", "Variable", "Valeur par défaut", "Rôle"
|
||||
c.ConfigDescriptions = [8]string{"Adresse d’écoute HTTP.", "URL publique sans barre oblique finale.", "Nom du produit ou du site.", "Page externe facultative de contact ou de projet.", "Destination des demandes Pro et commerciales.", "Langue de repli.", "Clé publique Ed25519 pour Pro.", "Jeton Pro signé et éventuellement lié au domaine."}
|
||||
c.FAQEyebrow, c.FAQTitle = "FAQ", "Questions fréquentes"
|
||||
c.FAQs = []FAQ{{"Community est-elle limitée artificiellement ?", "Non. Les modèles, huit langues, SVG, pages, JSON-LD, validation et fichiers de déploiement sont complets. Seule la personnalisation commerciale nécessite Pro."}, {"Pro dépend-il d'un serveur de licence ?", "Non. Chaque réplique vérifie localement la signature Ed25519."}, {"Puis-je utiliser un CDN ?", "Oui. Les badges et manifestes sont déterministes et utilisent ETag et Cache-Control."}, {"Cela garantit-il la conformité juridique ?", "Non. Le service fournit une infrastructure technique et ne remplace ni l'analyse juridique ni la responsabilité éditoriale."}}
|
||||
c.FAQs = []FAQ{{"Community est-elle limitée artificiellement ?", "Non. Les modèles, huit langues, SVG, pages, JSON-LD, validation et fichiers de déploiement sont complets. Seule la personnalisation commerciale nécessite Pro."}, {"Pro dépend-il d’un serveur de licence ?", "Non. Chaque réplique vérifie localement la signature Ed25519."}, {"Puis-je utiliser un CDN ?", "Oui. Les badges et manifestes sont déterministes et utilisent ETag et Cache-Control."}, {"Cela garantit-il la conformité juridique ?", "Non. Le service fournit une infrastructure technique et ne remplace ni l’analyse juridique ni la responsabilité éditoriale."}}
|
||||
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Commencez ouvert. Ajoutez votre marque uniquement si nécessaire.", "Le générateur fonctionne sans inscription. Pro ajoute les textes, couleurs et domaines personnalisés.", "Ouvrir le générateur", "Discuter de Pro"
|
||||
c.Footer = "Norme ouverte de déclaration d'utilisation de l'IA · Pas un conseil juridique."
|
||||
c.Footer = "Norme ouverte de déclaration d’utilisation de l’IA · Pas un conseil juridique."
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -457,7 +472,7 @@ func spanish() copySet {
|
||||
c.Features = []Feature{{"Visible", "Insignias SVG", "Insignias deterministas y cacheables desde preajustes o parámetros estructurados."}, {"Comprensible", "Páginas de declaración", "Explican la contribución de la IA, las actividades, la revisión y la responsabilidad."}, {"Legible por máquina", "JSON-LD y esquema", "Manifiestos para procesos de build y CMS."}, {"Internacional", "Ocho idiomas", "Alemán, inglés, francés, español, italiano, neerlandés, portugués y polaco."}, {"Pro", "Textos y diseño propios", "Títulos, descripciones, etiquetas y colores protegidos en el servidor."}, {"Sin conexión", "Licencia firmada", "Verificación Ed25519 local sin servidor externo."}, {"Escalable", "Alta disponibilidad", "Réplicas sin estado, probes, HPA, Swarm y caché CDN."}, {"Privacidad", "Sin seguimiento obligatorio", "Sin cookies, sesiones, recursos externos ni analítica obligatoria."}}
|
||||
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Ediciones", "Estándar abierto o integración de marca.", "Los formatos básicos siguen siendo gratuitos. Pro solo desbloquea la personalización comercial."
|
||||
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Función", "Community", "Pro"
|
||||
c.Comparison = commonComparison("es", "Incluido", "-", "Incluido", "Según plan")
|
||||
c.Comparison = commonComparison("es", "Incluido", "–", "Incluido", "Según plan")
|
||||
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Precios", "Escala según el alcance del despliegue.", "El software sigue siendo autoalojable. Los planes de pago licencian personalización, dominios y soporte."
|
||||
c.PricePeriod, c.PriceNote = "/ mes", "Precios de lanzamiento con facturación anual, impuestos no incluidos. Enterprise se acuerda individualmente."
|
||||
c.PlanDescriptions = [5]string{"Para uso open source y declaraciones estándar.", "Para sitios profesionales con presentación propia.", "Para editores con varias marcas o portales.", "Para agencias con despliegues repetidos para clientes.", "Para requisitos de contrato, SLA y despliegue personalizados."}
|
||||
@@ -478,18 +493,18 @@ func spanish() copySet {
|
||||
|
||||
func italian() copySet {
|
||||
c := english()
|
||||
c.MetaDescription = "Prodotto, prezzi e installazione dello standard aperto per dichiarare l'uso dell'IA."
|
||||
c.MetaDescription = "Prodotto, prezzi e installazione dello standard aperto per dichiarare l’uso dell’IA."
|
||||
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Funzioni", "Prezzi", "Installazione", "Generatore", "Contesto", "Lingua"
|
||||
c.HeroEyebrow, c.HeroTitle = "Core open source · Pro quando serve", "Dichiara l'uso dell'IA senza vincoli di piattaforma."
|
||||
c.HeroLead = "Un servizio Go stateless per badge SVG, pagine esplicative e JSON-LD. Self-hosted, internazionale e pronto per l'alta disponibilità."
|
||||
c.HeroEyebrow, c.HeroTitle = "Core open source · Pro quando serve", "Dichiara l’uso dell’IA senza vincoli di piattaforma."
|
||||
c.HeroLead = "Un servizio Go stateless per badge SVG, pagine esplicative e JSON-LD. Self-hosted, internazionale e pronto per l’alta disponibilità."
|
||||
c.PrimaryCTA, c.SecondaryCTA = "Crea un badge", "Contatta Pro"
|
||||
c.Proof = []string{"8 lingue", "Nessun cookie", "Nessun database", "Docker e Kubernetes"}
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funzioni", "Dall'avviso visibile alla dichiarazione leggibile dalle macchine.", "Community copre l'integrazione aperta. Pro aggiunge personalizzazione e licenza commerciale."
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funzioni", "Dall’avviso visibile alla dichiarazione leggibile dalle macchine.", "Community copre l’integrazione aperta. Pro aggiunge personalizzazione e licenza commerciale."
|
||||
c.Features = []Feature{{"Visibile", "Badge SVG", "Badge deterministici e cacheabili da preset o parametri strutturati."}, {"Comprensibile", "Pagine di dichiarazione", "Spiegano contributo IA, attività, revisione e responsabilità."}, {"Machine-readable", "JSON-LD e schema", "Manifesti integrabili in build e CMS."}, {"Internazionale", "Otto lingue", "Tedesco, inglese, francese, spagnolo, italiano, olandese, portoghese e polacco."}, {"Pro", "Testi e design personalizzati", "Titoli, descrizioni, etichette e colori protetti lato server."}, {"Offline", "Licenza firmata", "Verifica Ed25519 locale senza server esterno."}, {"Scalabile", "Alta disponibilità", "Repliche stateless, probe, HPA, Swarm e cache CDN."}, {"Privacy", "Nessun tracking obbligatorio", "Nessun cookie, sessione, asset esterno o analytics obbligatorio."}}
|
||||
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Edizioni", "Standard aperto o integrazione del brand.", "I formati essenziali restano gratuiti. Pro abilita solo la personalizzazione commerciale."
|
||||
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Funzione", "Community", "Pro"
|
||||
c.Comparison = commonComparison("it", "Incluso", "-", "Incluso", "Secondo il piano")
|
||||
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Prezzi", "Scala in base all'ampiezza del deployment.", "Il software resta self-hosted. I piani a pagamento licenziano personalizzazioni, domini e supporto."
|
||||
c.Comparison = commonComparison("it", "Incluso", "–", "Incluso", "Secondo il piano")
|
||||
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Prezzi", "Scala in base all’ampiezza del deployment.", "Il software resta self-hosted. I piani a pagamento licenziano personalizzazioni, domini e supporto."
|
||||
c.PricePeriod, c.PriceNote = "/ mese", "Prezzi introduttivi con fatturazione annuale, imposte escluse. Enterprise su accordo individuale."
|
||||
c.PlanDescriptions = [5]string{"Per open source e dichiarazioni standard.", "Per siti professionali con presentazione personalizzata.", "Per publisher con più brand o portali.", "Per agenzie con deployment ripetuti per i clienti.", "Per requisiti contrattuali, SLA e deployment personalizzati."}
|
||||
c.PlanFeatures = [5][]string{{"Preset standard", "8 lingue", "SVG, HTML e JSON-LD", "Self-hosting"}, {"Fino a 3 domini", "Testi personalizzati", "Etichette e colori personalizzati", "Licenza offline firmata"}, {"Fino a 20 domini", "Tutte le funzioni Pro", "Supporto prioritario", "Assistenza migrazione"}, {"Fino a 100 domini", "Domini clienti", "Uso commerciale agenzia", "Onboarding tecnico"}, {"Domini personalizzati", "SLA e finestre di supporto", "Distribuzione privata", "Termini personalizzati"}}
|
||||
@@ -497,13 +512,13 @@ func italian() copySet {
|
||||
c.EnterprisePrice = "Personalizzato"
|
||||
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Deployment", "In produzione con pochi comandi.", "Ogni opzione esegue lo stesso binario Go. Community e Pro differiscono solo per secret di licenza opzionali."
|
||||
c.CopyLabel, c.CopiedLabel = "Copia", "Copiato"
|
||||
c.InstallSummaries = [5]string{"Configurazione dichiarativa locale o su server singolo.", "Costruisci l'immagine ed esegui un container hardened.", "Tre repliche, probe, rolling update, HPA e PodDisruptionBudget.", "Più repliche con l'orchestratore nativo Docker.", "Per sviluppo, test o deployment nativo systemd."}
|
||||
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variabili d'ambiente importanti", "Variabile", "Default", "Scopo"
|
||||
c.InstallSummaries = [5]string{"Configurazione dichiarativa locale o su server singolo.", "Costruisci l’immagine ed esegui un container hardened.", "Tre repliche, probe, rolling update, HPA e PodDisruptionBudget.", "Più repliche con l’orchestratore nativo Docker.", "Per sviluppo, test o deployment nativo systemd."}
|
||||
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variabili d’ambiente importanti", "Variabile", "Default", "Scopo"
|
||||
c.ConfigDescriptions = [8]string{"Indirizzo di ascolto HTTP.", "URL pubblico senza slash finale.", "Titolo del prodotto o sito.", "Pagina esterna facoltativa di contatto o del progetto.", "Destinazione delle richieste Pro e commerciali.", "Lingua di fallback.", "Chiave pubblica Ed25519 per Pro.", "Token Pro firmato e opzionalmente legato ai domini."}
|
||||
c.FAQEyebrow, c.FAQTitle = "FAQ", "Domande frequenti"
|
||||
c.FAQs = []FAQ{{"Community è limitata artificialmente?", "No. Preset, otto lingue, SVG, pagine, JSON-LD, validazione e file di deployment sono completi. Solo la personalizzazione commerciale richiede Pro."}, {"Pro dipende da un server di licenze?", "No. Ogni replica verifica localmente la firma Ed25519."}, {"Posso usare un CDN?", "Sì. Badge e manifesti sono deterministici e includono ETag e Cache-Control."}, {"Garantisce conformità legale?", "No. Fornisce infrastruttura tecnica e non sostituisce la revisione legale o la responsabilità editoriale."}}
|
||||
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Inizia aperto. Aggiungi il brand solo quando serve.", "Il generatore funziona senza registrazione. Pro aggiunge testi, colori e domini personalizzati.", "Apri generatore", "Parla di Pro"
|
||||
c.Footer = "Standard aperto per dichiarare l'uso dell'IA · Non è consulenza legale."
|
||||
c.Footer = "Standard aperto per dichiarare l’uso dell’IA · Non è consulenza legale."
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -512,27 +527,27 @@ func dutch() copySet {
|
||||
c.MetaDescription = "Product, prijzen en installatie voor de open standaard voor AI-gebruiksverklaringen."
|
||||
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Functies", "Prijzen", "Installatie", "Generator", "Achtergrond", "Taal"
|
||||
c.HeroEyebrow, c.HeroTitle = "Open-source kern · Pro waar nodig", "Maak AI-gebruik transparant zonder platformlock-in."
|
||||
c.HeroLead = "Een stateless Go-service voor SVG-badges, begrijpelijke verklaringspagina's en JSON-LD. Zelf te hosten, internationaal en gebouwd voor hoge beschikbaarheid."
|
||||
c.HeroLead = "Een stateless Go-service voor SVG-badges, begrijpelijke verklaringspagina’s en JSON-LD. Zelf te hosten, internationaal en gebouwd voor hoge beschikbaarheid."
|
||||
c.PrimaryCTA, c.SecondaryCTA = "Badge maken", "Pro bespreken"
|
||||
c.Proof = []string{"8 talen", "Geen cookies", "Geen database", "Docker & Kubernetes"}
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Functies", "Van zichtbare melding tot machineleesbare verklaring.", "Community biedt de open integratie. Pro voegt maatwerk en commerciële licenties toe."
|
||||
c.Features = []Feature{{"Zichtbaar", "SVG-badges", "Deterministische, cachebare badges uit presets of gestructureerde parameters."}, {"Begrijpelijk", "Verklaringspagina's", "Leggen AI-bijdrage, activiteiten, menselijke controle en verantwoordelijkheid uit."}, {"Machineleesbaar", "JSON-LD en schema", "Manifesten voor build- en CMS-workflows."}, {"Internationaal", "Acht talen", "Duits, Engels, Frans, Spaans, Italiaans, Nederlands, Portugees en Pools."}, {"Pro", "Eigen teksten en ontwerp", "Titels, beschrijvingen, labels en kleuren met server-side handhaving."}, {"Offline", "Ondertekende licentie", "Lokale Ed25519-controle zonder externe licentieserver."}, {"Schaalbaar", "Hoge beschikbaarheid", "Stateless replicas, probes, HPA, Swarm en CDN-caching."}, {"Privacy", "Geen verplichte tracking", "Geen cookies, sessies, externe assets of verplichte analytics."}}
|
||||
c.Features = []Feature{{"Zichtbaar", "SVG-badges", "Deterministische, cachebare badges uit presets of gestructureerde parameters."}, {"Begrijpelijk", "Verklaringspagina’s", "Leggen AI-bijdrage, activiteiten, menselijke controle en verantwoordelijkheid uit."}, {"Machineleesbaar", "JSON-LD en schema", "Manifesten voor build- en CMS-workflows."}, {"Internationaal", "Acht talen", "Duits, Engels, Frans, Spaans, Italiaans, Nederlands, Portugees en Pools."}, {"Pro", "Eigen teksten en ontwerp", "Titels, beschrijvingen, labels en kleuren met server-side handhaving."}, {"Offline", "Ondertekende licentie", "Lokale Ed25519-controle zonder externe licentieserver."}, {"Schaalbaar", "Hoge beschikbaarheid", "Stateless replicas, probes, HPA, Swarm en CDN-caching."}, {"Privacy", "Geen verplichte tracking", "Geen cookies, sessies, externe assets of verplichte analytics."}}
|
||||
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Edities", "Open standaard of integratie in eigen huisstijl.", "Alle kernformaten blijven gratis. Pro ontgrendelt alleen commerciële aanpassing."
|
||||
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Functie", "Community", "Pro"
|
||||
c.Comparison = commonComparison("nl", "Inbegrepen", "-", "Inbegrepen", "Volgens abonnement")
|
||||
c.Comparison = commonComparison("nl", "Inbegrepen", "–", "Inbegrepen", "Volgens abonnement")
|
||||
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Prijzen", "Schaal mee met de omvang van je deployment.", "De software blijft zelf te hosten. Betaalde plannen licentiëren maatwerk, domeinen en support."
|
||||
c.PricePeriod, c.PriceNote = "/ maand", "Introductieprijzen bij jaarlijkse facturatie, exclusief belastingen. Enterprise op maat."
|
||||
c.PlanDescriptions = [5]string{"Voor open source en standaardverklaringen.", "Voor professionele sites met eigen presentatie.", "Voor uitgevers met meerdere merken of portals.", "Voor bureaus met herhaalde klantdeployments.", "Voor maatwerkcontracten, SLA's en deployments."}
|
||||
c.PlanDescriptions = [5]string{"Voor open source en standaardverklaringen.", "Voor professionele sites met eigen presentatie.", "Voor uitgevers met meerdere merken of portals.", "Voor bureaus met herhaalde klantdeployments.", "Voor maatwerkcontracten, SLA’s en deployments."}
|
||||
c.PlanFeatures = [5][]string{{"Standaardpresets", "8 talen", "SVG, HTML en JSON-LD", "Self-hosting"}, {"Tot 3 domeinen", "Eigen teksten", "Eigen labels en kleuren", "Ondertekende offline licentie"}, {"Tot 20 domeinen", "Alle Pro-functies", "Prioriteitssupport", "Migratiehulp"}, {"Tot 100 domeinen", "Klantdomeinen", "Commercieel bureaugebruik", "Technische onboarding"}, {"Domeinen op maat", "SLA en supportvensters", "Private distributie", "Aangepaste licentievoorwaarden"}}
|
||||
c.PlanCTA = [5]string{"Gratis starten", "Pro aanvragen", "Publisher aanvragen", "Agency aanvragen", "Contact opnemen"}
|
||||
c.EnterprisePrice = "Op maat"
|
||||
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Deployment", "Productieklaar in enkele commando's.", "Elke optie draait hetzelfde Go-binary. Community en Pro verschillen alleen door optionele licentie-secrets."
|
||||
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Deployment", "Productieklaar in enkele commando’s.", "Elke optie draait hetzelfde Go-binary. Community en Pro verschillen alleen door optionele licentie-secrets."
|
||||
c.CopyLabel, c.CopiedLabel = "Kopiëren", "Gekopieerd"
|
||||
c.InstallSummaries = [5]string{"Declaratieve setup voor lokaal of één server.", "Bouw het image en start een hardened container.", "Drie replicas, probes, rolling updates, HPA en PodDisruptionBudget.", "Meerdere replicas met Docker Swarm.", "Voor ontwikkeling, tests of een native systemd-deployment."}
|
||||
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Belangrijke omgevingsvariabelen", "Variabele", "Standaard", "Doel"
|
||||
c.ConfigDescriptions = [8]string{"HTTP-luisteradres.", "Publieke basis-URL zonder afsluitende slash.", "Product- of sitetitel.", "Optionele externe contact- of projectpagina.", "Bestemming voor Pro- en verkoopvragen.", "Fallbacktaal.", "Publieke Ed25519-sleutel voor Pro.", "Ondertekende en optioneel domeingebonden Pro-token."}
|
||||
c.FAQEyebrow, c.FAQTitle = "FAQ", "Veelgestelde vragen"
|
||||
c.FAQs = []FAQ{{"Is Community kunstmatig beperkt?", "Nee. Presets, acht talen, SVG, pagina's, JSON-LD, validatie en deploymentbestanden zijn volledig. Alleen commercieel maatwerk vereist Pro."}, {"Heeft Pro een licentieserver nodig?", "Nee. Elke replica controleert de Ed25519-handtekening lokaal."}, {"Kan dit achter een CDN?", "Ja. Badges en manifesten zijn deterministisch en gebruiken ETag en Cache-Control."}, {"Garandeert dit juridische naleving?", "Nee. Het is technische infrastructuur en vervangt geen juridische beoordeling of redactionele verantwoordelijkheid."}}
|
||||
c.FAQs = []FAQ{{"Is Community kunstmatig beperkt?", "Nee. Presets, acht talen, SVG, pagina’s, JSON-LD, validatie en deploymentbestanden zijn volledig. Alleen commercieel maatwerk vereist Pro."}, {"Heeft Pro een licentieserver nodig?", "Nee. Elke replica controleert de Ed25519-handtekening lokaal."}, {"Kan dit achter een CDN?", "Ja. Badges en manifesten zijn deterministisch en gebruiken ETag en Cache-Control."}, {"Garandeert dit juridische naleving?", "Nee. Het is technische infrastructuur en vervangt geen juridische beoordeling of redactionele verantwoordelijkheid."}}
|
||||
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Begin open. Voeg huisstijl toe waar nodig.", "De generator werkt zonder registratie. Pro voegt eigen teksten, kleuren en domeinen toe.", "Open generator", "Pro bespreken"
|
||||
c.Footer = "Open standaard voor AI-gebruiksverklaringen · Geen juridisch advies."
|
||||
return c
|
||||
@@ -550,7 +565,7 @@ func portuguese() copySet {
|
||||
c.Features = []Feature{{"Visível", "Badges SVG", "Badges determinísticos e cacheáveis a partir de presets ou parâmetros estruturados."}, {"Compreensível", "Páginas de declaração", "Explicam contribuição da IA, atividades, revisão e responsabilidade."}, {"Legível por máquina", "JSON-LD e esquema", "Manifestos para pipelines de build e CMS."}, {"Internacional", "Oito idiomas", "Alemão, inglês, francês, espanhol, italiano, neerlandês, português e polaco."}, {"Pro", "Textos e design próprios", "Títulos, descrições, etiquetas e cores protegidos no servidor."}, {"Offline", "Licença assinada", "Validação Ed25519 local sem servidor externo."}, {"Escalável", "Alta disponibilidade", "Réplicas stateless, probes, HPA, Swarm e cache CDN."}, {"Privacidade", "Sem tracking obrigatório", "Sem cookies, sessões, recursos externos ou analytics obrigatórios."}}
|
||||
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Edições", "Padrão aberto ou integração de marca.", "Os formatos essenciais continuam gratuitos. Pro desbloqueia apenas personalização comercial."
|
||||
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Funcionalidade", "Community", "Pro"
|
||||
c.Comparison = commonComparison("pt", "Incluído", "-", "Incluído", "Conforme o plano")
|
||||
c.Comparison = commonComparison("pt", "Incluído", "–", "Incluído", "Conforme o plano")
|
||||
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Preços", "Escale com a dimensão do deployment.", "O software continua autoalojável. Os planos pagos licenciam personalização, domínios e suporte."
|
||||
c.PricePeriod, c.PriceNote = "/ mês", "Preços de lançamento com faturação anual, impostos não incluídos. Enterprise sob proposta."
|
||||
c.PlanDescriptions = [5]string{"Para open source e declarações padrão.", "Para sites profissionais com apresentação própria.", "Para publishers com várias marcas ou portais.", "Para agências com deployments repetidos para clientes.", "Para requisitos contratuais, SLA e deployments personalizados."}
|
||||
@@ -581,7 +596,7 @@ func polish() copySet {
|
||||
c.Features = []Feature{{"Widoczne", "Plakietki SVG", "Deterministyczne, buforowalne plakietki z presetów lub parametrów."}, {"Zrozumiałe", "Strony deklaracji", "Opisują udział AI, działania, kontrolę człowieka i odpowiedzialność."}, {"Maszynowe", "JSON-LD i schemat", "Manifesty do procesów build i CMS."}, {"Międzynarodowe", "Osiem języków", "Niemiecki, angielski, francuski, hiszpański, włoski, niderlandzki, portugalski i polski."}, {"Pro", "Własne teksty i wygląd", "Tytuły, opisy, etykiety i kolory chronione po stronie serwera."}, {"Offline", "Podpisana licencja", "Lokalna weryfikacja Ed25519 bez zewnętrznego serwera."}, {"Skalowalne", "Wysoka dostępność", "Bezstanowe repliki, probes, HPA, Swarm i cache CDN."}, {"Prywatność", "Bez obowiązkowego śledzenia", "Bez cookies, sesji, zewnętrznych zasobów i obowiązkowej analityki."}}
|
||||
c.CompareEyebrow, c.CompareTitle, c.CompareLead = "Edycje", "Otwarty standard albo integracja z marką.", "Wszystkie podstawowe formaty pozostają bezpłatne. Pro odblokowuje wyłącznie personalizację komercyjną."
|
||||
c.CompareFeature, c.CompareCommunity, c.ComparePro = "Funkcja", "Community", "Pro"
|
||||
c.Comparison = commonComparison("pl", "W cenie", "-", "W cenie", "Zależnie od planu")
|
||||
c.Comparison = commonComparison("pl", "W cenie", "–", "W cenie", "Zależnie od planu")
|
||||
c.PricingEyebrow, c.PricingTitle, c.PricingLead = "Cennik", "Skaluj wraz z zakresem wdrożenia.", "Oprogramowanie pozostaje do samodzielnego hostowania. Płatne plany licencjonują personalizację, domeny i wsparcie."
|
||||
c.PricePeriod, c.PriceNote = "/ miesiąc", "Ceny wprowadzające przy rozliczeniu rocznym, bez podatków. Enterprise wyceniany indywidualnie."
|
||||
c.PlanDescriptions = [5]string{"Dla open source i standardowych deklaracji.", "Dla profesjonalnych stron z własnym wyglądem.", "Dla wydawców z wieloma markami lub portalami.", "Dla agencji wdrażających rozwiązanie u klientów.", "Dla indywidualnych umów, SLA i wdrożeń."}
|
||||
|
||||
@@ -4,22 +4,12 @@ import "testing"
|
||||
|
||||
func TestBuildAllLanguages(t *testing.T) {
|
||||
for _, lang := range []string{"de", "en", "fr", "es", "it", "nl", "pt", "pl"} {
|
||||
page := Build(lang, "Test Product", "https://example.org", "https://example.org/sales", "https://example.org/about", Prices{})
|
||||
if page.HeroTitle == "" || page.PricingTitle == "" || page.InstallTitle == "" {
|
||||
page := Build(lang, "Test Product", "https://example.org", "https://example.org/about")
|
||||
if page.HeroTitle == "" || page.InstallTitle == "" {
|
||||
t.Fatalf("%s has incomplete primary copy", lang)
|
||||
}
|
||||
if len(page.Features) != 7 || len(page.Comparison) != 10 || len(page.Plans) != 5 || len(page.InstallMethods) != 5 || len(page.Config) != 8 || len(page.FAQs) != 2 {
|
||||
t.Fatalf("%s has incomplete marketing data: features=%d comparison=%d plans=%d install=%d config=%d faq=%d", lang, len(page.Features), len(page.Comparison), len(page.Plans), len(page.InstallMethods), len(page.Config), len(page.FAQs))
|
||||
}
|
||||
if page.Plans[1].Price != "19 €" || page.Plans[4].Period != "" {
|
||||
t.Fatalf("%s has unexpected default pricing", lang)
|
||||
if len(page.Features) != 7 || len(page.InstallMethods) != 5 || len(page.Config) != 10 || len(page.FAQs) != 2 {
|
||||
t.Fatalf("%s has incomplete product data: features=%d install=%d config=%d faq=%d", lang, len(page.Features), len(page.InstallMethods), len(page.Config), len(page.FAQs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPriceOverrides(t *testing.T) {
|
||||
page := Build("en", "Test", "https://example.org", "https://example.org/sales", "", Prices{Pro: "$29", Publisher: "$99", Agency: "$249"})
|
||||
if page.Plans[1].Price != "$29" || page.Plans[2].Price != "$99" || page.Plans[3].Price != "$249" {
|
||||
t.Fatalf("price overrides not applied: %#v", page.Plans)
|
||||
}
|
||||
}
|
||||
|
||||
342
openapi.yaml
342
openapi.yaml
@@ -1,8 +1,10 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AI Usage Disclosure API
|
||||
version: 1.6.3
|
||||
description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities.
|
||||
version: 2.0.2
|
||||
description: >-
|
||||
Stateless multilingual API for visible and machine-readable AI usage declarations,
|
||||
non-binding Article 50 decision support, validation and optional licensed bulk/export capabilities.
|
||||
servers:
|
||||
- url: https://ai.example.org
|
||||
paths:
|
||||
@@ -17,7 +19,9 @@ paths:
|
||||
- {$ref: '#/components/parameters/language'}
|
||||
- {$ref: '#/components/parameters/theme'}
|
||||
- {$ref: '#/components/parameters/style'}
|
||||
- {name: link, in: query, schema: {type: string, description: Use auto to link to the generated declaration page.}}
|
||||
- name: link
|
||||
in: query
|
||||
schema: {type: string, description: Use auto to link to the generated declaration page.}
|
||||
responses:
|
||||
"200":
|
||||
description: SVG badge
|
||||
@@ -25,42 +29,54 @@ paths:
|
||||
image/svg+xml: {schema: {type: string}}
|
||||
/v1/badge.svg:
|
||||
get:
|
||||
summary: Render an SVG badge from structured parameters
|
||||
description: Custom labels and colours require the custom_badge capability.
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/extent'}
|
||||
- {$ref: '#/components/parameters/language'}
|
||||
- {name: preset, in: query, schema: {type: string, enum: [no-ai, research, summary, full]}}
|
||||
- {$ref: '#/components/parameters/legalContext'}
|
||||
- {$ref: '#/components/parameters/theme'}
|
||||
- {$ref: '#/components/parameters/style'}
|
||||
- {name: link, in: query, schema: {type: string}}
|
||||
- {name: badgeLabel, in: query, description: Licensed feature custom_badge, schema: {type: string, maxLength: 40}}
|
||||
- {name: badgeMessage, in: query, description: Licensed feature custom_badge, schema: {type: string, maxLength: 80}}
|
||||
- {name: leftColor, in: query, description: Licensed feature custom_badge, schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}}
|
||||
- {name: rightColor, in: query, description: Licensed feature custom_badge, schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}}
|
||||
responses:
|
||||
"200":
|
||||
description: SVG badge
|
||||
content:
|
||||
image/svg+xml: {schema: {type: string}}
|
||||
"403": {$ref: '#/components/responses/ProRequired'}
|
||||
/declaration:
|
||||
get:
|
||||
summary: Render a human-readable declaration page
|
||||
summary: Render an SVG badge from declaration parameters
|
||||
description: Custom labels and colours require the licensed capability `custom_badge`.
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/commonMode'}
|
||||
- {$ref: '#/components/parameters/preset'}
|
||||
- {$ref: '#/components/parameters/extent'}
|
||||
- {$ref: '#/components/parameters/language'}
|
||||
- {$ref: '#/components/parameters/component'}
|
||||
- {$ref: '#/components/parameters/activities'}
|
||||
- {$ref: '#/components/parameters/review'}
|
||||
- {$ref: '#/components/parameters/legalContext'}
|
||||
- {name: responsibleRole, in: query, schema: {type: string, enum: [publisher, other]}}
|
||||
- {$ref: '#/components/parameters/assurance'}
|
||||
- {$ref: '#/components/parameters/publicInterestText'}
|
||||
- {$ref: '#/components/parameters/deepfake'}
|
||||
- {$ref: '#/components/parameters/theme'}
|
||||
- {$ref: '#/components/parameters/style'}
|
||||
- {name: link, in: query, schema: {type: string}}
|
||||
- {$ref: '#/components/parameters/badgeLabel'}
|
||||
- {$ref: '#/components/parameters/badgeMessage'}
|
||||
- {$ref: '#/components/parameters/leftColor'}
|
||||
- {$ref: '#/components/parameters/rightColor'}
|
||||
responses:
|
||||
"200":
|
||||
description: SVG badge
|
||||
content:
|
||||
image/svg+xml: {schema: {type: string}}
|
||||
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
|
||||
/declaration:
|
||||
get:
|
||||
summary: Render a human-readable declaration page
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/commonMode'}
|
||||
- {$ref: '#/components/parameters/preset'}
|
||||
- {$ref: '#/components/parameters/extent'}
|
||||
- {$ref: '#/components/parameters/language'}
|
||||
- {$ref: '#/components/parameters/component'}
|
||||
- {$ref: '#/components/parameters/activities'}
|
||||
- {$ref: '#/components/parameters/review'}
|
||||
- {name: subject, in: query, schema: {type: string, format: uri}}
|
||||
- {name: responsible, in: query, schema: {type: string, maxLength: 200}}
|
||||
- {name: responsibleUrl, in: query, schema: {type: string, format: uri}}
|
||||
- {$ref: '#/components/parameters/legalContext'}
|
||||
- {$ref: '#/components/parameters/assurance'}
|
||||
- {$ref: '#/components/parameters/publicInterestText'}
|
||||
- {$ref: '#/components/parameters/deepfake'}
|
||||
- {$ref: '#/components/parameters/artisticContext'}
|
||||
- {$ref: '#/components/parameters/substantialHumanReview'}
|
||||
- {$ref: '#/components/parameters/editorialResponsibilityConfirmed'}
|
||||
- {$ref: '#/components/parameters/firstExposureDisclosure'}
|
||||
- {$ref: '#/components/parameters/accessibilityConsidered'}
|
||||
- {$ref: '#/components/parameters/customTitle'}
|
||||
- {$ref: '#/components/parameters/customDescription'}
|
||||
- {$ref: '#/components/parameters/badgeLabel'}
|
||||
@@ -69,11 +85,12 @@ paths:
|
||||
- {$ref: '#/components/parameters/rightColor'}
|
||||
responses:
|
||||
"200": {description: Human-readable HTML declaration}
|
||||
"403": {$ref: '#/components/responses/ProRequired'}
|
||||
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
|
||||
/v1/declaration.json:
|
||||
get:
|
||||
summary: Generate a JSON-LD declaration from query parameters
|
||||
summary: Generate a JSON-LD declaration
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/commonMode'}
|
||||
- {$ref: '#/components/parameters/preset'}
|
||||
- {$ref: '#/components/parameters/extent'}
|
||||
- {$ref: '#/components/parameters/language'}
|
||||
@@ -81,10 +98,16 @@ paths:
|
||||
- {$ref: '#/components/parameters/activities'}
|
||||
- {$ref: '#/components/parameters/review'}
|
||||
- {name: subject, in: query, schema: {type: string, format: uri}}
|
||||
- {name: responsibleRole, in: query, schema: {type: string, enum: [publisher, other]}}
|
||||
- {name: responsible, in: query, schema: {type: string, maxLength: 200}}
|
||||
- {name: responsibleUrl, in: query, schema: {type: string, format: uri}}
|
||||
- {$ref: '#/components/parameters/assurance'}
|
||||
- {$ref: '#/components/parameters/publicInterestText'}
|
||||
- {$ref: '#/components/parameters/deepfake'}
|
||||
- {$ref: '#/components/parameters/artisticContext'}
|
||||
- {$ref: '#/components/parameters/substantialHumanReview'}
|
||||
- {$ref: '#/components/parameters/editorialResponsibilityConfirmed'}
|
||||
- {$ref: '#/components/parameters/firstExposureDisclosure'}
|
||||
- {$ref: '#/components/parameters/accessibilityConsidered'}
|
||||
- {$ref: '#/components/parameters/customTitle'}
|
||||
- {$ref: '#/components/parameters/customDescription'}
|
||||
- {$ref: '#/components/parameters/badgeLabel'}
|
||||
@@ -97,7 +120,126 @@ paths:
|
||||
content:
|
||||
application/ld+json:
|
||||
schema: {$ref: './schema/declaration.schema.json'}
|
||||
"403": {$ref: '#/components/responses/ProRequired'}
|
||||
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
|
||||
/v1/article50-assessment.json:
|
||||
get:
|
||||
summary: Return non-binding technical Article 50 decision support
|
||||
description: >-
|
||||
Evaluates the factual regulatory context supplied with a declaration. The result is
|
||||
technical decision support, not legal advice or a legal determination.
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/commonMode'}
|
||||
- {$ref: '#/components/parameters/preset'}
|
||||
- {$ref: '#/components/parameters/extent'}
|
||||
- {$ref: '#/components/parameters/language'}
|
||||
- {$ref: '#/components/parameters/component'}
|
||||
- {$ref: '#/components/parameters/activities'}
|
||||
- {$ref: '#/components/parameters/review'}
|
||||
- {name: subject, in: query, schema: {type: string, format: uri}}
|
||||
- {name: responsible, in: query, schema: {type: string, maxLength: 200}}
|
||||
- {name: responsibleUrl, in: query, schema: {type: string, format: uri}}
|
||||
- {$ref: '#/components/parameters/assurance'}
|
||||
- {$ref: '#/components/parameters/publicInterestText'}
|
||||
- {$ref: '#/components/parameters/deepfake'}
|
||||
- {$ref: '#/components/parameters/artisticContext'}
|
||||
- {$ref: '#/components/parameters/substantialHumanReview'}
|
||||
- {$ref: '#/components/parameters/editorialResponsibilityConfirmed'}
|
||||
- {$ref: '#/components/parameters/firstExposureDisclosure'}
|
||||
- {$ref: '#/components/parameters/accessibilityConsidered'}
|
||||
- {$ref: '#/components/parameters/customTitle'}
|
||||
- {$ref: '#/components/parameters/customDescription'}
|
||||
- {$ref: '#/components/parameters/badgeLabel'}
|
||||
- {$ref: '#/components/parameters/badgeMessage'}
|
||||
- {$ref: '#/components/parameters/leftColor'}
|
||||
- {$ref: '#/components/parameters/rightColor'}
|
||||
responses:
|
||||
"200":
|
||||
description: Assessment with localized summary and warnings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [assessment, title, summary, disclaimer]
|
||||
properties:
|
||||
assessment: {$ref: '#/components/schemas/Article50Assessment'}
|
||||
title: {type: string}
|
||||
summary: {type: string}
|
||||
findings: {type: array, items: {type: string}}
|
||||
warnings: {type: array, items: {type: string}}
|
||||
disclaimer: {type: string}
|
||||
/v1/declaration.bundle.json:
|
||||
get:
|
||||
summary: Export a declaration evidence bundle
|
||||
description: Requires the licensed capability `export_bundle`.
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/commonMode'}
|
||||
- {$ref: '#/components/parameters/preset'}
|
||||
- {$ref: '#/components/parameters/extent'}
|
||||
- {$ref: '#/components/parameters/language'}
|
||||
- {$ref: '#/components/parameters/component'}
|
||||
- {$ref: '#/components/parameters/activities'}
|
||||
- {$ref: '#/components/parameters/review'}
|
||||
- {name: subject, in: query, schema: {type: string, format: uri}}
|
||||
- {name: responsible, in: query, schema: {type: string, maxLength: 200}}
|
||||
- {name: responsibleUrl, in: query, schema: {type: string, format: uri}}
|
||||
- {$ref: '#/components/parameters/assurance'}
|
||||
- {$ref: '#/components/parameters/publicInterestText'}
|
||||
- {$ref: '#/components/parameters/deepfake'}
|
||||
- {$ref: '#/components/parameters/artisticContext'}
|
||||
- {$ref: '#/components/parameters/substantialHumanReview'}
|
||||
- {$ref: '#/components/parameters/editorialResponsibilityConfirmed'}
|
||||
- {$ref: '#/components/parameters/firstExposureDisclosure'}
|
||||
- {$ref: '#/components/parameters/accessibilityConsidered'}
|
||||
- {$ref: '#/components/parameters/customTitle'}
|
||||
- {$ref: '#/components/parameters/customDescription'}
|
||||
- {$ref: '#/components/parameters/badgeLabel'}
|
||||
- {$ref: '#/components/parameters/badgeMessage'}
|
||||
- {$ref: '#/components/parameters/leftColor'}
|
||||
- {$ref: '#/components/parameters/rightColor'}
|
||||
responses:
|
||||
"200":
|
||||
description: Declaration, Article 50 assessment, SHA-256 declaration digest and canonical output URLs
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/DeclarationBundle'}
|
||||
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
|
||||
/v1/bulk/declarations:
|
||||
post:
|
||||
summary: Process multiple declarations in one server-to-server request
|
||||
description: >-
|
||||
Requires the licensed capability `bulk_api`. The dedicated bulk container additionally
|
||||
requires an API key by default and enforces configured/licensed item limits.
|
||||
security:
|
||||
- bulkBearer: []
|
||||
- bulkApiKey: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
id: {type: string}
|
||||
parameters:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
required: [parameters]
|
||||
responses:
|
||||
"200":
|
||||
description: Per-item declaration and assessment results
|
||||
"401":
|
||||
description: Bulk API authentication required when configured
|
||||
"403": {$ref: '#/components/responses/LicensedCapabilityRequired'}
|
||||
"413": {description: Configured or licensed bulk limit exceeded}
|
||||
/v1/validate:
|
||||
post:
|
||||
summary: Validate an AI usage declaration
|
||||
@@ -111,7 +253,7 @@ paths:
|
||||
"422": {description: Validation failed}
|
||||
/v1/capabilities:
|
||||
get:
|
||||
summary: Return edition, licensed features and supported languages
|
||||
summary: Return runtime mode, licence state, licensed capabilities and languages
|
||||
responses:
|
||||
"200":
|
||||
description: Runtime capabilities
|
||||
@@ -120,23 +262,17 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
license:
|
||||
type: object
|
||||
required: [edition, licensed, features]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
edition: {type: string}
|
||||
licensed: {type: boolean}
|
||||
features:
|
||||
type: array
|
||||
items: {type: string}
|
||||
limits:
|
||||
type: object
|
||||
additionalProperties: {type: integer, format: int64}
|
||||
expiresAt: {type: string, format: date-time}
|
||||
product: {type: string, const: ai-disclosure-standard}
|
||||
productVersion: {type: string}
|
||||
schemaVersion: {type: string}
|
||||
serviceMode: {type: string, enum: [full, api, bulk]}
|
||||
license: {type: object}
|
||||
supportedLanguages:
|
||||
type: array
|
||||
items: {type: string}
|
||||
items: {type: string, enum: [de, en, fr, es, it, nl, pt, pl]}
|
||||
licensedCapabilities:
|
||||
type: object
|
||||
additionalProperties: {type: boolean}
|
||||
/healthz:
|
||||
get:
|
||||
summary: Liveness endpoint
|
||||
@@ -144,9 +280,28 @@ paths:
|
||||
/readyz:
|
||||
get:
|
||||
summary: Readiness endpoint
|
||||
responses: {"200": {description: Ready}}
|
||||
description: In bulk mode this also checks the bulk licence capability and required API-key configuration.
|
||||
responses:
|
||||
"200": {description: Ready}
|
||||
"503": {description: Runtime is not ready}
|
||||
/metrics:
|
||||
get:
|
||||
summary: Prometheus metrics
|
||||
responses: {"200": {description: Prometheus text exposition}}
|
||||
components:
|
||||
securitySchemes:
|
||||
bulkBearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bulkApiKey:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
parameters:
|
||||
commonMode:
|
||||
name: mode
|
||||
in: query
|
||||
schema: {type: string, enum: [single, article]}
|
||||
preset:
|
||||
name: preset
|
||||
in: query
|
||||
@@ -162,7 +317,7 @@ components:
|
||||
component:
|
||||
name: component
|
||||
in: query
|
||||
schema: {type: string, enum: [text, coverImage, image, audio, video, code, other], default: text}
|
||||
schema: {type: string, enum: [text, coverImage, image, research, translation, audio, video, code, other], default: text}
|
||||
activities:
|
||||
name: activities
|
||||
in: query
|
||||
@@ -170,21 +325,46 @@ components:
|
||||
review:
|
||||
name: review
|
||||
in: query
|
||||
schema: {type: string, enum: [none, basic, editorial, expert], default: none}
|
||||
legalContext:
|
||||
name: legalContext
|
||||
in: query
|
||||
description: Comma-separated self-classification for Article 50 context. This is supporting metadata, not an automatic legal determination.
|
||||
schema: {type: string, example: publicInterestText}
|
||||
schema: {type: string, enum: [none, basic, editorial, expert], default: editorial}
|
||||
assurance:
|
||||
name: assurance
|
||||
in: query
|
||||
description: Evidence basis for the declaration.
|
||||
schema: {type: string, enum: [selfDeclared, technicallyRecorded, signed, verified], default: selfDeclared}
|
||||
publicInterestText:
|
||||
name: publicInterestText
|
||||
in: query
|
||||
description: Factual flag that the text informs the public on a matter of public interest.
|
||||
schema: {type: boolean}
|
||||
deepfake:
|
||||
name: deepfake
|
||||
in: query
|
||||
description: Factual flag for deepfake or realistic AI-generated/manipulated content.
|
||||
schema: {type: boolean}
|
||||
artisticContext:
|
||||
name: artisticCreativeSatiricalFictional
|
||||
in: query
|
||||
schema: {type: boolean}
|
||||
substantialHumanReview:
|
||||
name: substantialHumanReview
|
||||
in: query
|
||||
schema: {type: boolean}
|
||||
editorialResponsibilityConfirmed:
|
||||
name: editorialResponsibilityConfirmed
|
||||
in: query
|
||||
schema: {type: boolean}
|
||||
firstExposureDisclosure:
|
||||
name: firstExposureDisclosure
|
||||
in: query
|
||||
schema: {type: boolean}
|
||||
accessibilityConsidered:
|
||||
name: accessibilityConsidered
|
||||
in: query
|
||||
schema: {type: boolean}
|
||||
theme:
|
||||
name: theme
|
||||
in: query
|
||||
schema: {type: string, enum: [mono, color, emoji], default: color}
|
||||
schema: {type: string, enum: [mono, color], default: color}
|
||||
style:
|
||||
name: style
|
||||
in: query
|
||||
@@ -192,41 +372,71 @@ components:
|
||||
customTitle:
|
||||
name: customTitle
|
||||
in: query
|
||||
description: Licensed feature custom_text
|
||||
description: Licensed capability `custom_text`.
|
||||
schema: {type: string, maxLength: 120}
|
||||
customDescription:
|
||||
name: customDescription
|
||||
in: query
|
||||
description: Licensed feature custom_text
|
||||
description: Licensed capability `custom_text`.
|
||||
schema: {type: string, maxLength: 500}
|
||||
badgeLabel:
|
||||
name: badgeLabel
|
||||
in: query
|
||||
description: Licensed feature custom_badge
|
||||
description: Licensed capability `custom_badge`.
|
||||
schema: {type: string, maxLength: 40}
|
||||
badgeMessage:
|
||||
name: badgeMessage
|
||||
in: query
|
||||
description: Licensed feature custom_badge
|
||||
description: Licensed capability `custom_badge`.
|
||||
schema: {type: string, maxLength: 80}
|
||||
leftColor:
|
||||
name: leftColor
|
||||
in: query
|
||||
description: Licensed feature custom_badge
|
||||
description: Licensed capability `custom_badge`.
|
||||
schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}
|
||||
rightColor:
|
||||
name: rightColor
|
||||
in: query
|
||||
description: Licensed feature custom_badge
|
||||
description: Licensed capability `custom_badge`.
|
||||
schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}
|
||||
schemas:
|
||||
Article50Assessment:
|
||||
type: object
|
||||
required: [code, severity, potentiallyApplicable]
|
||||
properties:
|
||||
code: {type: string}
|
||||
severity: {type: string, enum: [neutral, caution, attention]}
|
||||
potentiallyApplicable: {type: boolean}
|
||||
findings:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [code, severity]
|
||||
properties:
|
||||
code: {type: string}
|
||||
severity: {type: string, enum: [neutral, caution, attention]}
|
||||
warnings: {type: array, items: {type: string}}
|
||||
DeclarationBundle:
|
||||
type: object
|
||||
required: [product, productVersion, generatedAt, declaration, declarationDigest, article50Assessment, declarationUrl, manifestUrl, badgeUrl]
|
||||
properties:
|
||||
product: {type: string, const: ai-disclosure-standard}
|
||||
productVersion: {type: string}
|
||||
generatedAt: {type: string, format: date-time}
|
||||
declaration: {$ref: './schema/declaration.schema.json'}
|
||||
declarationDigest: {type: string, pattern: '^sha256:[0-9a-f]{64}$'}
|
||||
article50Assessment: {$ref: '#/components/schemas/Article50Assessment'}
|
||||
declarationUrl: {type: string, format: uri}
|
||||
manifestUrl: {type: string, format: uri}
|
||||
badgeUrl: {type: string, format: uri}
|
||||
responses:
|
||||
ProRequired:
|
||||
LicensedCapabilityRequired:
|
||||
description: A valid licensed capability is required
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {const: pro_feature_required}
|
||||
code: {const: licensed_feature_required}
|
||||
status: {const: 403}
|
||||
detail: {type: string}
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
package licenseclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Edition string `json:"edition"`
|
||||
Licensed bool `json:"licensed"`
|
||||
LicenseID string `json:"licenseId,omitempty"`
|
||||
Customer string `json:"customer,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Features []string `json:"features"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LastChecked string `json:"lastChecked,omitempty"`
|
||||
LeaseExpires string `json:"leaseExpiresAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Product string
|
||||
Token string
|
||||
TrustStore licensekit.TrustStore
|
||||
BaseURL string
|
||||
InstanceID string
|
||||
Mode licensekit.VerificationMode
|
||||
ServerURL string
|
||||
CacheFile string
|
||||
RefreshEvery time.Duration
|
||||
RequestTimeout time.Duration
|
||||
ClientVersion string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
claims licensekit.Claims
|
||||
features map[string]bool
|
||||
stopOnce sync.Once
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
type introspectRequest struct {
|
||||
Token string `json:"token"`
|
||||
Product string `json:"product"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Host string `json:"host"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
ClientVersion string `json:"clientVersion,omitempty"`
|
||||
}
|
||||
|
||||
type introspectResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
LeaseToken string `json:"leaseToken,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type cacheDocument struct {
|
||||
LicenseID string `json:"licenseId"`
|
||||
Lease string `json:"lease"`
|
||||
SavedAt int64 `json:"savedAt"`
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg Config) *Client {
|
||||
if cfg.RefreshEvery <= 0 {
|
||||
cfg.RefreshEvery = 15 * time.Minute
|
||||
}
|
||||
if cfg.RequestTimeout <= 0 {
|
||||
cfg.RequestTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = &http.Client{Timeout: cfg.RequestTimeout}
|
||||
}
|
||||
c := &Client{cfg: cfg, stop: make(chan struct{}), status: communityStatus(), features: map[string]bool{}}
|
||||
c.refresh(ctx)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) Start(ctx context.Context) {
|
||||
c.mu.RLock()
|
||||
mode := c.status.Mode
|
||||
c.mu.RUnlock()
|
||||
if mode == string(licensekit.ModeOffline) || strings.TrimSpace(c.cfg.Token) == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(c.cfg.RefreshEvery)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
refreshCtx, cancel := context.WithTimeout(context.Background(), c.cfg.RequestTimeout)
|
||||
c.refresh(refreshCtx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Client) Close() { c.stopOnce.Do(func() { close(c.stop) }) }
|
||||
|
||||
func (c *Client) Refresh(ctx context.Context) Status {
|
||||
c.refresh(ctx)
|
||||
return c.Status()
|
||||
}
|
||||
|
||||
func (c *Client) Has(feature string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature]
|
||||
}
|
||||
|
||||
func (c *Client) Limit(name string) (int64, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
|
||||
return 0, false
|
||||
}
|
||||
value, ok := c.status.Limits[name]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (c *Client) Status() Status {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := c.status
|
||||
if out.Licensed && !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
|
||||
out.Licensed = false
|
||||
out.Edition = "community"
|
||||
if out.Reason == "" {
|
||||
out.Reason = "license or online lease is no longer valid"
|
||||
}
|
||||
}
|
||||
out.Features = append([]string{}, c.status.Features...)
|
||||
if c.status.Limits != nil {
|
||||
out.Limits = make(map[string]int64, len(c.status.Limits))
|
||||
for key, value := range c.status.Limits {
|
||||
out.Limits[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Client) isCurrentlyLicensedLocked(now time.Time) bool {
|
||||
if !c.status.Licensed {
|
||||
return false
|
||||
}
|
||||
if c.claims.ExpiresAt > 0 && now.Unix() >= c.claims.ExpiresAt {
|
||||
return false
|
||||
}
|
||||
mode := licensekit.VerificationMode(c.status.Mode)
|
||||
if mode == licensekit.ModeOffline || c.status.LeaseExpires == "" {
|
||||
return true
|
||||
}
|
||||
leaseExpiry, err := time.Parse(time.RFC3339, c.status.LeaseExpires)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if mode == licensekit.ModeHybrid {
|
||||
leaseExpiry = leaseExpiry.Add(time.Duration(c.claims.Verification.OfflineGraceSeconds) * time.Second)
|
||||
}
|
||||
return now.Before(leaseExpiry)
|
||||
}
|
||||
|
||||
func (c *Client) refresh(ctx context.Context) {
|
||||
now := time.Now().UTC()
|
||||
if strings.TrimSpace(c.cfg.Token) == "" {
|
||||
c.apply(communityStatus(), licensekit.Claims{})
|
||||
return
|
||||
}
|
||||
verified, err := licensekit.VerifyLicense(c.cfg.TrustStore, c.cfg.Token, now)
|
||||
if err != nil {
|
||||
c.apply(failedStatus(err.Error(), now), licensekit.Claims{})
|
||||
return
|
||||
}
|
||||
claims := verified.Claims
|
||||
if err := licensekit.ValidateLicenseContext(claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
|
||||
c.apply(failedStatus(err.Error(), now), claims)
|
||||
return
|
||||
}
|
||||
mode := licensekit.StricterMode(claims.Verification.Mode, c.cfg.Mode)
|
||||
base := statusFromClaims(claims, mode, now)
|
||||
if mode == licensekit.ModeOffline {
|
||||
base.Licensed = true
|
||||
base.Source = "offline"
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(c.cfg.ServerURL) == "" {
|
||||
base.Edition = "community"
|
||||
base.Reason = "online verification is required but LICENSE_SERVER_URL is empty"
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
lease, source, err := c.obtainLease(ctx, claims, mode, now)
|
||||
if err != nil {
|
||||
base.Edition = "community"
|
||||
base.Reason = err.Error()
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
base.Licensed = true
|
||||
base.Source = source
|
||||
base.LeaseExpires = time.Unix(lease.ExpiresAt, 0).UTC().Format(time.RFC3339)
|
||||
base.Features = licensekit.UniqueSorted(intersection(claims.Features, lease.Features))
|
||||
c.apply(base, claims)
|
||||
}
|
||||
|
||||
func (c *Client) obtainLease(ctx context.Context, claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) (licensekit.LeaseClaims, string, error) {
|
||||
leaseToken, err := c.requestLease(ctx)
|
||||
if err == nil {
|
||||
lease, verifyErr := licensekit.VerifyLease(c.cfg.TrustStore, leaseToken, now, 0)
|
||||
if verifyErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online lease verification failed: %w", verifyErr)
|
||||
}
|
||||
if verifyErr = licensekit.ValidateLeaseContext(lease.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); verifyErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", verifyErr
|
||||
}
|
||||
_ = c.writeCache(claims.LicenseID, leaseToken)
|
||||
return lease.Claims, "online", nil
|
||||
}
|
||||
if mode == licensekit.ModeOnline {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed: %w", err)
|
||||
}
|
||||
cached, cacheErr := c.readCache(claims, now)
|
||||
if cacheErr != nil {
|
||||
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed (%v) and no usable cached lease exists (%v)", err, cacheErr)
|
||||
}
|
||||
return cached, "cached-lease", nil
|
||||
}
|
||||
|
||||
func (c *Client) requestLease(ctx context.Context) (string, error) {
|
||||
host, err := licensekit.HostFromBaseURL(c.cfg.BaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(introspectRequest{Token: c.cfg.Token, Product: c.cfg.Product, BaseURL: c.cfg.BaseURL, Host: host, InstanceID: c.cfg.InstanceID, ClientVersion: c.cfg.ClientVersion})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
endpoint := strings.TrimRight(c.cfg.ServerURL, "/") + "/v1/introspect"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result introspectResponse
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return "", fmt.Errorf("decode verification response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" {
|
||||
if result.Reason == "" {
|
||||
result.Reason = resp.Status
|
||||
}
|
||||
return "", errors.New(result.Reason)
|
||||
}
|
||||
return result.LeaseToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) readCache(claims licensekit.Claims, now time.Time) (licensekit.LeaseClaims, error) {
|
||||
if strings.TrimSpace(c.cfg.CacheFile) == "" {
|
||||
return licensekit.LeaseClaims{}, errors.New("cache file is not configured")
|
||||
}
|
||||
data, err := os.ReadFile(c.cfg.CacheFile)
|
||||
if err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
var doc cacheDocument
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
if doc.LicenseID != claims.LicenseID {
|
||||
return licensekit.LeaseClaims{}, errors.New("cached lease belongs to another license")
|
||||
}
|
||||
grace := time.Duration(claims.Verification.OfflineGraceSeconds) * time.Second
|
||||
verified, err := licensekit.VerifyLease(c.cfg.TrustStore, doc.Lease, now, grace)
|
||||
if err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
if err := licensekit.ValidateLeaseContext(verified.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
|
||||
return licensekit.LeaseClaims{}, err
|
||||
}
|
||||
return verified.Claims, nil
|
||||
}
|
||||
|
||||
func (c *Client) writeCache(licenseID, lease string) error {
|
||||
if strings.TrimSpace(c.cfg.CacheFile) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(c.cfg.CacheFile), 0o700); err != nil && filepath.Dir(c.cfg.CacheFile) != "." {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(cacheDocument{LicenseID: licenseID, Lease: lease, SavedAt: time.Now().UTC().Unix()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temp := c.cfg.CacheFile + ".tmp"
|
||||
if err := os.WriteFile(temp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp, c.cfg.CacheFile)
|
||||
}
|
||||
|
||||
func (c *Client) apply(status Status, claims licensekit.Claims) {
|
||||
status.Features = licensekit.UniqueSorted(status.Features)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.status = status
|
||||
c.claims = claims
|
||||
c.features = make(map[string]bool, len(status.Features))
|
||||
for _, feature := range status.Features {
|
||||
c.features[feature] = true
|
||||
}
|
||||
}
|
||||
|
||||
func communityStatus() Status {
|
||||
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}}
|
||||
}
|
||||
|
||||
func failedStatus(reason string, now time.Time) Status {
|
||||
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}, Reason: reason, LastChecked: now.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) Status {
|
||||
limits := map[string]int64{}
|
||||
for key, value := range claims.Limits {
|
||||
limits[key] = value
|
||||
}
|
||||
return Status{
|
||||
Edition: claims.Edition, LicenseID: claims.LicenseID, Customer: claims.Customer, Product: claims.Product,
|
||||
Features: append([]string(nil), claims.Features...), Limits: limits,
|
||||
ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339), Mode: string(mode), LastChecked: now.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func intersection(a, b []string) []string {
|
||||
allowed := make(map[string]bool, len(b))
|
||||
for _, value := range b {
|
||||
allowed[value] = true
|
||||
}
|
||||
out := make([]string, 0, len(a))
|
||||
for _, value := range a {
|
||||
if allowed[value] {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NewDevelopment returns an in-memory licensed client for local development.
|
||||
// Production applications should not expose this path without an explicit development switch.
|
||||
func NewDevelopment(product, edition string, features []string) *Client {
|
||||
status := Status{Edition: edition, Licensed: true, Customer: "development", Product: product, Features: licensekit.UniqueSorted(features), Limits: map[string]int64{}, Mode: string(licensekit.ModeOffline), Source: "development", Reason: "insecure development override"}
|
||||
c := &Client{status: status, features: map[string]bool{}, stop: make(chan struct{})}
|
||||
for _, feature := range status.Features {
|
||||
c.features[feature] = true
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package licenseclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
|
||||
)
|
||||
|
||||
func keys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func licenseToken(t *testing.T, mode licensekit.VerificationMode, store *licensekit.TrustStore) (string, licensekit.Claims) {
|
||||
t.Helper()
|
||||
pub, priv := keys(t)
|
||||
store.LicenseKeys["issuer"] = licensekit.EncodeKey(pub)
|
||||
now := time.Now().UTC()
|
||||
claims := licensekit.Claims{Version: 1, LicenseID: "lic_1", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", Features: []string{"feature_a", "feature_b"}, Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: mode, LeaseTTLSeconds: 600, OfflineGraceSeconds: 3600}}
|
||||
token, err := licensekit.SignLicense(priv, "issuer", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token, claims
|
||||
}
|
||||
|
||||
func TestOfflineClient(t *testing.T) {
|
||||
store := licensekit.NewTrustStore()
|
||||
token, _ := licenseToken(t, licensekit.ModeOffline, &store)
|
||||
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org"})
|
||||
if !c.Status().Licensed || !c.Has("feature_a") {
|
||||
t.Fatalf("unexpected status %#v", c.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridClientUsesOnlineLeaseAndCache(t *testing.T) {
|
||||
store := licensekit.NewTrustStore()
|
||||
token, claims := licenseToken(t, licensekit.ModeHybrid, &store)
|
||||
leasePub, leasePriv := keys(t)
|
||||
store.LeaseKeys["lease"] = licensekit.EncodeKey(leasePub)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().UTC()
|
||||
lease := licensekit.LeaseClaims{Version: 1, LeaseID: "lease_1", LicenseID: claims.LicenseID, Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(5 * time.Minute).Unix()}
|
||||
leaseToken, err := licensekit.SignLease(leasePriv, "lease", lease)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": leaseToken})
|
||||
}))
|
||||
cache := filepath.Join(t.TempDir(), "lease.json")
|
||||
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeHybrid, ServerURL: server.URL, CacheFile: cache})
|
||||
if !c.Status().Licensed || c.Status().Source != "online" {
|
||||
t.Fatalf("unexpected online status %#v", c.Status())
|
||||
}
|
||||
server.Close()
|
||||
c.Refresh(context.Background())
|
||||
if !c.Status().Licensed || c.Status().Source != "cached-lease" {
|
||||
t.Fatalf("unexpected cached status %#v", c.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineModeFailsWithoutServer(t *testing.T) {
|
||||
store := licensekit.NewTrustStore()
|
||||
token, _ := licenseToken(t, licensekit.ModeOnline, &store)
|
||||
c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeOnline})
|
||||
if c.Status().Licensed {
|
||||
t.Fatalf("online license unexpectedly active %#v", c.Status())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Package licenseclient implements the runtime side of product licensing. It
|
||||
// supports offline verification, hybrid signed-lease caching and mandatory
|
||||
// online introspection without allowing customers to replace trusted keys.
|
||||
package licenseclient
|
||||
@@ -1,3 +0,0 @@
|
||||
// Package licensekit implements product-neutral Ed25519 licence and lease
|
||||
// tokens, embedded trust stores, context validation and key rotation by key ID.
|
||||
package licensekit
|
||||
@@ -1,472 +0,0 @@
|
||||
package licensekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TokenTypeLicense = "LICENSE"
|
||||
TokenTypeLease = "LEASE"
|
||||
AlgorithmEdDSA = "EdDSA"
|
||||
SchemaVersion = 1
|
||||
)
|
||||
|
||||
type VerificationMode string
|
||||
|
||||
const (
|
||||
ModeOffline VerificationMode = "offline"
|
||||
ModeHybrid VerificationMode = "hybrid"
|
||||
ModeOnline VerificationMode = "online"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version int `json:"v"`
|
||||
}
|
||||
|
||||
type VerificationPolicy struct {
|
||||
Mode VerificationMode `json:"mode"`
|
||||
LeaseTTLSeconds int64 `json:"leaseTtlSeconds,omitempty"`
|
||||
OfflineGraceSeconds int64 `json:"offlineGraceSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version int `json:"version"`
|
||||
LicenseID string `json:"licenseId"`
|
||||
Issuer string `json:"issuer"`
|
||||
Customer string `json:"customer"`
|
||||
Product string `json:"product"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
InstanceIDs []string `json:"instanceIds,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
NotBefore int64 `json:"notBefore,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Verification VerificationPolicy `json:"verification"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type LeaseClaims struct {
|
||||
Version int `json:"version"`
|
||||
LeaseID string `json:"leaseId"`
|
||||
LicenseID string `json:"licenseId"`
|
||||
Product string `json:"product"`
|
||||
Customer string `json:"customer"`
|
||||
Edition string `json:"edition"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
IssuedAt int64 `json:"issuedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type TrustStore struct {
|
||||
LicenseKeys map[string]string `json:"licenseKeys"`
|
||||
LeaseKeys map[string]string `json:"leaseKeys"`
|
||||
}
|
||||
|
||||
type VerifiedLicense struct {
|
||||
Header Header
|
||||
Claims Claims
|
||||
}
|
||||
|
||||
type VerifiedLease struct {
|
||||
Header Header
|
||||
Claims LeaseClaims
|
||||
}
|
||||
|
||||
func NewTrustStore() TrustStore {
|
||||
return TrustStore{LicenseKeys: map[string]string{}, LeaseKeys: map[string]string{}}
|
||||
}
|
||||
|
||||
func ParseTrustStore(data []byte) (TrustStore, error) {
|
||||
var store TrustStore
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&store); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("decode trust store: %w", err)
|
||||
}
|
||||
if store.LicenseKeys == nil {
|
||||
store.LicenseKeys = map[string]string{}
|
||||
}
|
||||
if store.LeaseKeys == nil {
|
||||
store.LeaseKeys = map[string]string{}
|
||||
}
|
||||
for kid, encoded := range store.LicenseKeys {
|
||||
if strings.TrimSpace(kid) == "" {
|
||||
return TrustStore{}, errors.New("license trust store contains an empty key id")
|
||||
}
|
||||
if _, err := DecodePublicKey(encoded); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("license key %q: %w", kid, err)
|
||||
}
|
||||
}
|
||||
for kid, encoded := range store.LeaseKeys {
|
||||
if strings.TrimSpace(kid) == "" {
|
||||
return TrustStore{}, errors.New("lease trust store contains an empty key id")
|
||||
}
|
||||
if _, err := DecodePublicKey(encoded); err != nil {
|
||||
return TrustStore{}, fmt.Errorf("lease key %q: %w", kid, err)
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func MarshalTrustStore(store TrustStore) ([]byte, error) {
|
||||
if store.LicenseKeys == nil {
|
||||
store.LicenseKeys = map[string]string{}
|
||||
}
|
||||
if store.LeaseKeys == nil {
|
||||
store.LeaseKeys = map[string]string{}
|
||||
}
|
||||
return json.MarshalIndent(store, "", " ")
|
||||
}
|
||||
|
||||
func SignLicense(privateKey ed25519.PrivateKey, keyID string, claims Claims) (string, error) {
|
||||
if err := validateLicenseClaims(claims, time.Unix(claims.IssuedAt, 0), false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sign(TokenTypeLicense, keyID, privateKey, claims)
|
||||
}
|
||||
|
||||
func SignLease(privateKey ed25519.PrivateKey, keyID string, claims LeaseClaims) (string, error) {
|
||||
if err := validateLeaseClaims(claims, time.Unix(claims.IssuedAt, 0), false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sign(TokenTypeLease, keyID, privateKey, claims)
|
||||
}
|
||||
|
||||
func sign(tokenType, keyID string, privateKey ed25519.PrivateKey, claims any) (string, error) {
|
||||
if len(privateKey) != ed25519.PrivateKeySize {
|
||||
return "", errors.New("invalid Ed25519 private key")
|
||||
}
|
||||
keyID = strings.TrimSpace(keyID)
|
||||
if keyID == "" {
|
||||
return "", errors.New("key id is required")
|
||||
}
|
||||
header := Header{Algorithm: AlgorithmEdDSA, Type: tokenType, KeyID: keyID, Version: SchemaVersion}
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal token header: %w", err)
|
||||
}
|
||||
payloadJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal token payload: %w", err)
|
||||
}
|
||||
headerPart := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
payloadPart := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
signingInput := headerPart + "." + payloadPart
|
||||
signature := ed25519.Sign(privateKey, []byte(signingInput))
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
func VerifyLicense(store TrustStore, token string, now time.Time) (VerifiedLicense, error) {
|
||||
header, payload, err := verifyToken(store.LicenseKeys, TokenTypeLicense, token)
|
||||
if err != nil {
|
||||
return VerifiedLicense{}, err
|
||||
}
|
||||
var claims Claims
|
||||
if err := decodeStrict(payload, &claims); err != nil {
|
||||
return VerifiedLicense{}, fmt.Errorf("decode license payload: %w", err)
|
||||
}
|
||||
if err := validateLicenseClaims(claims, now, true); err != nil {
|
||||
return VerifiedLicense{}, err
|
||||
}
|
||||
claims.Features = UniqueSorted(claims.Features)
|
||||
claims.Domains = UniqueSorted(claims.Domains)
|
||||
claims.InstanceIDs = UniqueSorted(claims.InstanceIDs)
|
||||
return VerifiedLicense{Header: header, Claims: claims}, nil
|
||||
}
|
||||
|
||||
func VerifyLease(store TrustStore, token string, now time.Time, allowGrace time.Duration) (VerifiedLease, error) {
|
||||
header, payload, err := verifyToken(store.LeaseKeys, TokenTypeLease, token)
|
||||
if err != nil {
|
||||
return VerifiedLease{}, err
|
||||
}
|
||||
var claims LeaseClaims
|
||||
if err := decodeStrict(payload, &claims); err != nil {
|
||||
return VerifiedLease{}, fmt.Errorf("decode lease payload: %w", err)
|
||||
}
|
||||
if err := validateLeaseClaims(claims, now, false); err != nil {
|
||||
return VerifiedLease{}, err
|
||||
}
|
||||
if now.Unix() >= claims.ExpiresAt+int64(allowGrace.Seconds()) {
|
||||
return VerifiedLease{}, errors.New("lease has expired")
|
||||
}
|
||||
claims.Features = UniqueSorted(claims.Features)
|
||||
return VerifiedLease{Header: header, Claims: claims}, nil
|
||||
}
|
||||
|
||||
func verifyToken(keys map[string]string, expectedType, token string) (Header, []byte, error) {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) != 3 {
|
||||
return Header{}, nil, errors.New("token has invalid format")
|
||||
}
|
||||
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token header is not valid base64url")
|
||||
}
|
||||
var header Header
|
||||
if err := decodeStrict(headerBytes, &header); err != nil {
|
||||
return Header{}, nil, fmt.Errorf("decode token header: %w", err)
|
||||
}
|
||||
if header.Algorithm != AlgorithmEdDSA || header.Type != expectedType || header.Version != SchemaVersion {
|
||||
return Header{}, nil, errors.New("unsupported token header")
|
||||
}
|
||||
encodedKey, ok := keys[header.KeyID]
|
||||
if !ok {
|
||||
return Header{}, nil, fmt.Errorf("token is signed by unknown key %q", header.KeyID)
|
||||
}
|
||||
publicKey, err := DecodePublicKey(encodedKey)
|
||||
if err != nil {
|
||||
return Header{}, nil, fmt.Errorf("decode trusted key %q: %w", header.KeyID, err)
|
||||
}
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token signature is not valid base64url")
|
||||
}
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
if !ed25519.Verify(publicKey, []byte(signingInput), signature) {
|
||||
return Header{}, nil, errors.New("token signature verification failed")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return Header{}, nil, errors.New("token payload is not valid base64url")
|
||||
}
|
||||
return header, payload, nil
|
||||
}
|
||||
|
||||
func ValidateLicenseContext(claims Claims, product, baseURL, instanceID string) error {
|
||||
if strings.TrimSpace(product) == "" {
|
||||
return errors.New("client product id is required")
|
||||
}
|
||||
if claims.Product != product {
|
||||
return fmt.Errorf("license is for product %q, not %q", claims.Product, product)
|
||||
}
|
||||
if err := ValidateDomain(claims.Domains, baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(claims.InstanceIDs) > 0 {
|
||||
instanceID = strings.TrimSpace(instanceID)
|
||||
if instanceID == "" {
|
||||
return errors.New("license requires an instance id")
|
||||
}
|
||||
allowed := false
|
||||
for _, candidate := range claims.InstanceIDs {
|
||||
if candidate == "*" || candidate == instanceID {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("instance %q is not covered by the license", instanceID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateLeaseContext(claims LeaseClaims, license Claims, product, baseURL, instanceID string) error {
|
||||
if claims.LicenseID != license.LicenseID {
|
||||
return errors.New("lease does not belong to the configured license")
|
||||
}
|
||||
if claims.Product != product || claims.Product != license.Product {
|
||||
return errors.New("lease product does not match")
|
||||
}
|
||||
host, err := HostFromBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if claims.Host != "" && !strings.EqualFold(claims.Host, host) {
|
||||
return errors.New("lease host does not match")
|
||||
}
|
||||
if claims.InstanceID != "" && claims.InstanceID != instanceID {
|
||||
return errors.New("lease instance does not match")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateDomain(domains []string, baseURL string) error {
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
}
|
||||
host, err := HostFromBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, allowed := range domains {
|
||||
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||
if allowed == "*" || host == allowed {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
root := strings.TrimPrefix(allowed, "*.")
|
||||
if host != root && strings.HasSuffix(host, "."+root) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("host %q is not covered by the license", host)
|
||||
}
|
||||
|
||||
func HostFromBaseURL(baseURL string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "", errors.New("base URL has no valid host")
|
||||
}
|
||||
return strings.ToLower(u.Hostname()), nil
|
||||
}
|
||||
|
||||
func StricterMode(a, b VerificationMode) VerificationMode {
|
||||
rank := map[VerificationMode]int{ModeOffline: 0, ModeHybrid: 1, ModeOnline: 2}
|
||||
if rank[b] > rank[a] {
|
||||
return b
|
||||
}
|
||||
if _, ok := rank[a]; !ok {
|
||||
return ModeOffline
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func ParseMode(value string) (VerificationMode, error) {
|
||||
mode := VerificationMode(strings.ToLower(strings.TrimSpace(value)))
|
||||
switch mode {
|
||||
case "", ModeOffline:
|
||||
return ModeOffline, nil
|
||||
case ModeHybrid, ModeOnline:
|
||||
return mode, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown verification mode %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func DecodePrivateKey(encoded string) (ed25519.PrivateKey, error) {
|
||||
b, err := decodeKey(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) == ed25519.SeedSize {
|
||||
return ed25519.NewKeyFromSeed(b), nil
|
||||
}
|
||||
if len(b) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("private key must contain an Ed25519 seed or private key")
|
||||
}
|
||||
return ed25519.PrivateKey(b), nil
|
||||
}
|
||||
|
||||
func DecodePublicKey(encoded string) (ed25519.PublicKey, error) {
|
||||
b, err := decodeKey(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("public key must contain an Ed25519 public key")
|
||||
}
|
||||
return ed25519.PublicKey(b), nil
|
||||
}
|
||||
|
||||
func EncodeKey(key []byte) string { return base64.RawURLEncoding.EncodeToString(key) }
|
||||
|
||||
func decodeKey(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("key is not valid base64")
|
||||
}
|
||||
|
||||
func UniqueSorted(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func validateLicenseClaims(c Claims, now time.Time, checkTime bool) error {
|
||||
if c.Version != SchemaVersion {
|
||||
return errors.New("unsupported license version")
|
||||
}
|
||||
if strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Issuer) == "" {
|
||||
return errors.New("license id and issuer are required")
|
||||
}
|
||||
if strings.TrimSpace(c.Customer) == "" || strings.TrimSpace(c.Product) == "" || strings.TrimSpace(c.Edition) == "" {
|
||||
return errors.New("customer, product and edition are required")
|
||||
}
|
||||
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
|
||||
return errors.New("license timestamps are invalid")
|
||||
}
|
||||
notBefore := c.NotBefore
|
||||
if notBefore == 0 {
|
||||
notBefore = c.IssuedAt
|
||||
}
|
||||
if checkTime {
|
||||
if now.Unix() < notBefore-300 {
|
||||
return errors.New("license is not active yet")
|
||||
}
|
||||
if now.Unix() >= c.ExpiresAt {
|
||||
return errors.New("license has expired")
|
||||
}
|
||||
}
|
||||
if _, err := ParseMode(string(c.Verification.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Verification.LeaseTTLSeconds < 0 || c.Verification.OfflineGraceSeconds < 0 {
|
||||
return errors.New("verification durations cannot be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLeaseClaims(c LeaseClaims, now time.Time, checkExpiration bool) error {
|
||||
if c.Version != SchemaVersion {
|
||||
return errors.New("unsupported lease version")
|
||||
}
|
||||
if strings.TrimSpace(c.LeaseID) == "" || strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Product) == "" {
|
||||
return errors.New("lease id, license id and product are required")
|
||||
}
|
||||
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
|
||||
return errors.New("lease timestamps are invalid")
|
||||
}
|
||||
if now.Unix() < c.IssuedAt-300 {
|
||||
return errors.New("lease is not active yet")
|
||||
}
|
||||
if checkExpiration && now.Unix() >= c.ExpiresAt {
|
||||
return errors.New("lease has expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeStrict(data []byte, target any) error {
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package licensekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testKeys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestLicenseRoundTripAndContext(t *testing.T) {
|
||||
pub, priv := testKeys(t)
|
||||
now := time.Now().UTC()
|
||||
claims := Claims{Version: 1, LicenseID: "lic_test", Issuer: "vendor", Customer: "customer", Product: "product-a", Edition: "pro", Features: []string{"b", "a"}, Domains: []string{"*.example.org"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: VerificationPolicy{Mode: ModeOffline}}
|
||||
token, err := SignLicense(priv, "issuer-1", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewTrustStore()
|
||||
store.LicenseKeys["issuer-1"] = EncodeKey(pub)
|
||||
verified, err := VerifyLicense(store, token, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if verified.Claims.Features[0] != "a" {
|
||||
t.Fatalf("features not sorted: %#v", verified.Claims.Features)
|
||||
}
|
||||
if err := ValidateLicenseContext(verified.Claims, "product-a", "https://app.example.org", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateLicenseContext(verified.Claims, "product-b", "https://app.example.org", ""); err == nil {
|
||||
t.Fatal("expected product mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalWildcardAllowsAllHosts(t *testing.T) {
|
||||
if err := ValidateDomain([]string{"*"}, "http://localhost:8080"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateDomain([]string{"*"}, "https://anything.invalid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownKeyIsRejected(t *testing.T) {
|
||||
_, priv := testKeys(t)
|
||||
now := time.Now().UTC()
|
||||
claims := Claims{Version: 1, LicenseID: "lic_test", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: VerificationPolicy{Mode: ModeOffline}}
|
||||
token, err := SignLicense(priv, "self-chosen", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := VerifyLicense(NewTrustStore(), token, now); err == nil {
|
||||
t.Fatal("untrusted user key must not be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseRoundTrip(t *testing.T) {
|
||||
pub, priv := testKeys(t)
|
||||
now := time.Now().UTC()
|
||||
claims := LeaseClaims{Version: 1, LeaseID: "lease_1", LicenseID: "lic_1", Product: "product", Customer: "customer", Edition: "pro", Features: []string{"x"}, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix()}
|
||||
token, err := SignLease(priv, "lease-1", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewTrustStore()
|
||||
store.LeaseKeys["lease-1"] = EncodeKey(pub)
|
||||
verified, err := VerifyLease(store, token, now, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
license := Claims{LicenseID: "lic_1", Product: "product"}
|
||||
if err := ValidateLeaseContext(verified.Claims, license, "product", "https://example.org", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Package licenseserver implements the optional central introspection,
|
||||
// registration, revocation and signed short-lived lease service. Its Registry
|
||||
// interface can be backed by a transactional shared database for HA use.
|
||||
package licenseserver
|
||||
@@ -1,271 +0,0 @@
|
||||
package licenseserver
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TrustStore licensekit.TrustStore
|
||||
LeasePrivateKey ed25519.PrivateKey
|
||||
LeaseKeyID string
|
||||
AdminToken string
|
||||
DefaultLeaseTTL time.Duration
|
||||
MaxLeaseTTL time.Duration
|
||||
}
|
||||
|
||||
type Registry interface {
|
||||
Get(id string) (Record, bool)
|
||||
List() []Record
|
||||
Put(record Record) error
|
||||
SetRevoked(id string, revoked bool, reason string) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
store Registry
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
type introspectRequest struct {
|
||||
Token string `json:"token"`
|
||||
Product string `json:"product"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Host string `json:"host"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
ClientVersion string `json:"clientVersion,omitempty"`
|
||||
}
|
||||
|
||||
type introspectResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
LeaseToken string `json:"leaseToken,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type revokeRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func New(cfg Config, store Registry, logger *slog.Logger) (*Server, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("license registry is required")
|
||||
}
|
||||
if len(cfg.LeasePrivateKey) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("a valid Ed25519 lease signing private key is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.LeaseKeyID) == "" {
|
||||
return nil, errors.New("lease key id is required")
|
||||
}
|
||||
if _, ok := cfg.TrustStore.LeaseKeys[cfg.LeaseKeyID]; !ok {
|
||||
return nil, fmt.Errorf("lease public key %q is not present in the trust store", cfg.LeaseKeyID)
|
||||
}
|
||||
if cfg.DefaultLeaseTTL <= 0 {
|
||||
cfg.DefaultLeaseTTL = time.Hour
|
||||
}
|
||||
if cfg.MaxLeaseTTL <= 0 {
|
||||
cfg.MaxLeaseTTL = 24 * time.Hour
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
s := &Server{cfg: cfg, store: store, logger: logger, mux: http.NewServeMux()}
|
||||
s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler { return s.securityHeaders(s.mux) }
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /healthz", s.health)
|
||||
s.mux.HandleFunc("POST /v1/introspect", s.introspect)
|
||||
s.mux.HandleFunc("GET /v1/admin/licenses", s.admin(s.list))
|
||||
s.mux.HandleFunc("POST /v1/admin/licenses", s.admin(s.register))
|
||||
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/revoke", s.admin(s.revoke))
|
||||
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/restore", s.admin(s.restore))
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) introspect(w http.ResponseWriter, r *http.Request) {
|
||||
var request introspectRequest
|
||||
if err := decodeBody(r, &request); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, introspectResponse{Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
verified, err := licensekit.VerifyLicense(s.cfg.TrustStore, request.Token, now)
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
claims := verified.Claims
|
||||
if err := licensekit.ValidateLicenseContext(claims, request.Product, request.BaseURL, request.InstanceID); err != nil {
|
||||
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
record, ok := s.store.Get(claims.LicenseID)
|
||||
if !ok {
|
||||
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "license is not registered"})
|
||||
return
|
||||
}
|
||||
if record.TokenHash != licensekit.TokenHash(request.Token) {
|
||||
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "registered token does not match"})
|
||||
return
|
||||
}
|
||||
if record.Revoked {
|
||||
reason := "license is revoked"
|
||||
if record.Reason != "" {
|
||||
reason += ": " + record.Reason
|
||||
}
|
||||
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: reason})
|
||||
return
|
||||
}
|
||||
ttl := s.cfg.DefaultLeaseTTL
|
||||
if claims.Verification.LeaseTTLSeconds > 0 {
|
||||
ttl = time.Duration(claims.Verification.LeaseTTLSeconds) * time.Second
|
||||
}
|
||||
if ttl > s.cfg.MaxLeaseTTL {
|
||||
ttl = s.cfg.MaxLeaseTTL
|
||||
}
|
||||
if remaining := time.Until(time.Unix(claims.ExpiresAt, 0)); ttl > remaining {
|
||||
ttl = remaining
|
||||
}
|
||||
if ttl <= 0 {
|
||||
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "license has expired"})
|
||||
return
|
||||
}
|
||||
host, _ := licensekit.HostFromBaseURL(request.BaseURL)
|
||||
leaseID := randomID("lease")
|
||||
leaseClaims := licensekit.LeaseClaims{
|
||||
Version: 1, LeaseID: leaseID, LicenseID: claims.LicenseID, Product: claims.Product,
|
||||
Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features,
|
||||
Host: host, InstanceID: request.InstanceID, IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(),
|
||||
}
|
||||
token, err := licensekit.SignLease(s.cfg.LeasePrivateKey, s.cfg.LeaseKeyID, leaseClaims)
|
||||
if err != nil {
|
||||
s.logger.Error("lease signing failed", "error", err, "license_id", claims.LicenseID)
|
||||
s.writeJSON(w, http.StatusInternalServerError, introspectResponse{Reason: "lease signing failed"})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, introspectResponse{Valid: true, LeaseToken: token, ExpiresAt: time.Unix(leaseClaims.ExpiresAt, 0).UTC().Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
func (s *Server) list(w http.ResponseWriter, _ *http.Request) {
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"licenses": s.store.List()})
|
||||
}
|
||||
|
||||
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
var request registerRequest
|
||||
if err := decodeBody(r, &request); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
verified, err := licensekit.VerifyLicense(s.cfg.TrustStore, request.Token, time.Now().UTC())
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
claims := verified.Claims
|
||||
record := Record{LicenseID: claims.LicenseID, TokenHash: licensekit.TokenHash(request.Token), Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, ExpiresAt: claims.ExpiresAt}
|
||||
if err := s.store.Put(record); err != nil {
|
||||
s.writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusCreated, record)
|
||||
}
|
||||
|
||||
func (s *Server) revoke(w http.ResponseWriter, r *http.Request) {
|
||||
var request revokeRequest
|
||||
_ = decodeBodyAllowEmpty(r, &request)
|
||||
if err := s.store.SetRevoked(r.PathValue("id"), true, strings.TrimSpace(request.Reason)); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"status": "revoked"})
|
||||
}
|
||||
|
||||
func (s *Server) restore(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.SetRevoked(r.PathValue("id"), false, ""); err != nil {
|
||||
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"status": "active"})
|
||||
}
|
||||
|
||||
func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := strings.TrimSpace(s.cfg.AdminToken)
|
||||
actual := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if expected == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
s.writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func decodeBody(r *http.Request, target any) error {
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeBodyAllowEmpty(r *http.Request, target any) error {
|
||||
err := decodeBody(r, target)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func randomID(prefix string) string {
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return prefix + "_" + fmt.Sprint(time.Now().UnixNano())
|
||||
}
|
||||
return prefix + "_" + hex.EncodeToString(raw[:])
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package licenseserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
|
||||
)
|
||||
|
||||
func keyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func TestRegisterIntrospectAndRevoke(t *testing.T) {
|
||||
issuerPub, issuerPriv := keyPair(t)
|
||||
leasePub, leasePriv := keyPair(t)
|
||||
storeKeys := licensekit.NewTrustStore()
|
||||
storeKeys.LicenseKeys["issuer"] = licensekit.EncodeKey(issuerPub)
|
||||
storeKeys.LeaseKeys["lease"] = licensekit.EncodeKey(leasePub)
|
||||
registry, err := OpenStore("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := New(Config{TrustStore: storeKeys, LeasePrivateKey: leasePriv, LeaseKeyID: "lease", AdminToken: "secret", DefaultLeaseTTL: time.Hour}, registry, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
now := time.Now().UTC()
|
||||
claims := licensekit.Claims{Version: 1, LicenseID: "lic_1", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", Features: []string{"feature"}, Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: licensekit.ModeHybrid, LeaseTTLSeconds: 300}}
|
||||
token, err := licensekit.SignLicense(issuerPriv, "issuer", claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registerBody, _ := json.Marshal(map[string]string{"token": token})
|
||||
req, _ := http.NewRequest(http.MethodPost, httpServer.URL+"/v1/admin/licenses", bytes.NewReader(registerBody))
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("register status %d", resp.StatusCode)
|
||||
}
|
||||
introBody, _ := json.Marshal(map[string]string{"token": token, "product": "product", "baseUrl": "https://example.org"})
|
||||
resp, err = http.Post(httpServer.URL+"/v1/introspect", "application/json", bytes.NewReader(introBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var intro map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&intro)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || intro["valid"] != true {
|
||||
t.Fatalf("introspection failed: %d %#v", resp.StatusCode, intro)
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodPost, httpServer.URL+"/v1/admin/licenses/lic_1/revoke", bytes.NewBufferString(`{"reason":"payment"}`))
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("revoke status %d", resp.StatusCode)
|
||||
}
|
||||
resp, err = http.Post(httpServer.URL+"/v1/introspect", "application/json", bytes.NewReader(introBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected revoked status, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package licenseserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
LicenseID string `json:"licenseId"`
|
||||
TokenHash string `json:"tokenHash"`
|
||||
Product string `json:"product"`
|
||||
Customer string `json:"customer"`
|
||||
Edition string `json:"edition"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Revoked bool `json:"revoked"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type fileDocument struct {
|
||||
Version int `json:"version"`
|
||||
Records []Record `json:"records"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
records map[string]Record
|
||||
}
|
||||
|
||||
func OpenStore(path string) (*Store, error) {
|
||||
s := &Store{path: path, records: map[string]Record{}}
|
||||
if path == "" {
|
||||
return s, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc fileDocument
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.Version != 1 {
|
||||
return nil, errors.New("unsupported license registry version")
|
||||
}
|
||||
for _, record := range doc.Records {
|
||||
s.records[record.LicenseID] = record
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (Record, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
record, ok := s.records[id]
|
||||
return record, ok
|
||||
}
|
||||
|
||||
func (s *Store) List() []Record {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Record, 0, len(s.records))
|
||||
for _, record := range s.records {
|
||||
out = append(out, record)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Put(record Record) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now().UTC().Unix()
|
||||
if existing, ok := s.records[record.LicenseID]; ok {
|
||||
record.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if record.CreatedAt == 0 {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
s.records[record.LicenseID] = record
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) SetRevoked(id string, revoked bool, reason string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, ok := s.records[id]
|
||||
if !ok {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
record.Revoked = revoked
|
||||
record.Reason = reason
|
||||
record.UpdatedAt = time.Now().UTC().Unix()
|
||||
s.records[id] = record
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) persistLocked() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil && filepath.Dir(s.path) != "." {
|
||||
return err
|
||||
}
|
||||
records := make([]Record, 0, len(s.records))
|
||||
for _, record := range s.records {
|
||||
records = append(records, record)
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool { return records[i].LicenseID < records[j].LicenseID })
|
||||
data, err := json.MarshalIndent(fileDocument{Version: 1, Records: records}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temp := s.path + ".tmp"
|
||||
if err := os.WriteFile(temp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp, s.path)
|
||||
}
|
||||
@@ -52,9 +52,6 @@
|
||||
"editorialResponsibility": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
@@ -62,15 +59,38 @@
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
}
|
||||
},
|
||||
"regulatoryContext": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"framework": {
|
||||
"enum": [
|
||||
"EU-AI-Act-Article-50"
|
||||
]
|
||||
},
|
||||
"assumed": {
|
||||
"publicInterestText": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"role": {
|
||||
"enum": [
|
||||
"publisher",
|
||||
"other"
|
||||
]
|
||||
"deepfake": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"artisticCreativeSatiricalFictional": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"substantialHumanReview": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"editorialResponsibilityConfirmed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"firstExposureDisclosure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"accessibilityConsidered": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -111,24 +131,6 @@
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"legalContext": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"enum": [
|
||||
"deepfake",
|
||||
"publicInterestText",
|
||||
"artisticCreativeSatiricalFictional",
|
||||
"otherVoluntary"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.org/schema/license-v1.json",
|
||||
"title": "Universal Product Licence Claims",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "licenseId", "issuer", "customer", "product", "edition", "issuedAt", "expiresAt", "verification"],
|
||||
"properties": {
|
||||
"version": {"const": 1},
|
||||
"licenseId": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"issuer": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"customer": {"type": "string", "minLength": 1, "maxLength": 300},
|
||||
"product": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||
"edition": {"type": "string", "minLength": 1, "maxLength": 100},
|
||||
"features": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
|
||||
"limits": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}},
|
||||
"domains": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
|
||||
"instanceIds": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
|
||||
"issuedAt": {"type": "integer", "minimum": 1},
|
||||
"notBefore": {"type": "integer", "minimum": 1},
|
||||
"expiresAt": {"type": "integer", "minimum": 1},
|
||||
"verification": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode"],
|
||||
"properties": {
|
||||
"mode": {"enum": ["offline", "hybrid", "online"]},
|
||||
"leaseTtlSeconds": {"type": "integer", "minimum": 0},
|
||||
"offlineGraceSeconds": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
},
|
||||
"metadata": {"type": "object", "additionalProperties": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.org/schema/license-trust-store-v1.json",
|
||||
"title": "Licence Trust Store",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["licenseKeys", "leaseKeys"],
|
||||
"properties": {
|
||||
"licenseKeys": {"type": "object", "additionalProperties": {"type": "string", "minLength": 40}},
|
||||
"leaseKeys": {"type": "object", "additionalProperties": {"type": "string", "minLength": 40}}
|
||||
}
|
||||
}
|
||||
10
secrets/README.md
Normal file
10
secrets/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Runtime secrets
|
||||
|
||||
Do not commit real secrets in this directory.
|
||||
|
||||
For `compose.commercial.yaml`, create:
|
||||
|
||||
- `secrets/license_token.txt` — token issued by the Universal License Platform
|
||||
- `secrets/bulk_api_key.txt` — a long random bearer/API key for the bulk endpoint
|
||||
|
||||
Both paths are ignored by `.gitignore`.
|
||||
@@ -134,7 +134,7 @@ func (c *Client) Refresh(ctx context.Context) Status {
|
||||
func (c *Client) Has(feature string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature]
|
||||
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && (c.features[feature] || c.features["*"])
|
||||
}
|
||||
|
||||
func (c *Client) Limit(name string) (int64, bool) {
|
||||
@@ -425,6 +425,26 @@ func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode
|
||||
}
|
||||
|
||||
func intersection(a, b []string) []string {
|
||||
aAll, bAll := false, false
|
||||
for _, value := range a {
|
||||
if value == "*" {
|
||||
aAll = true
|
||||
}
|
||||
}
|
||||
for _, value := range b {
|
||||
if value == "*" {
|
||||
bAll = true
|
||||
}
|
||||
}
|
||||
if aAll && bAll {
|
||||
return []string{"*"}
|
||||
}
|
||||
if aAll {
|
||||
return licensekit.UniqueSorted(b)
|
||||
}
|
||||
if bAll {
|
||||
return licensekit.UniqueSorted(a)
|
||||
}
|
||||
allowed := make(map[string]bool, len(b))
|
||||
for _, value := range b {
|
||||
allowed[value] = true
|
||||
@@ -435,5 +455,5 @@ func intersection(a, b []string) []string {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return licensekit.UniqueSorted(out)
|
||||
}
|
||||
|
||||
@@ -62,3 +62,14 @@ func TestHybridUsesPlatformValidateEndpoint(t *testing.T) {
|
||||
t.Fatalf("unexpected endpoint %q", requestedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureWildcardIntersection(t *testing.T) {
|
||||
got := intersection([]string{"*"}, []string{"custom_text", "bulk_api"})
|
||||
if len(got) != 2 || got[0] != "bulk_api" || got[1] != "custom_text" {
|
||||
t.Fatalf("unexpected wildcard intersection: %v", got)
|
||||
}
|
||||
got = intersection([]string{"custom_text", "bulk_api"}, []string{"*"})
|
||||
if len(got) != 2 || got[0] != "bulk_api" || got[1] != "custom_text" {
|
||||
t.Fatalf("unexpected inverse wildcard intersection: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
if (!form) return;
|
||||
const $ = id => document.getElementById(id);
|
||||
const presets = {
|
||||
// Never assume human/editorial review. It can be legally relevant under
|
||||
// Article 50(4) AI Act and must reflect the publisher's actual workflow.
|
||||
'no-ai': {extent:'none', activities:'', review:'none'},
|
||||
research: {extent:'assisted', activities:'research', review:'none'},
|
||||
summary: {extent:'assisted', activities:'summarisation', review:'none'},
|
||||
full: {extent:'full', activities:'generation', review:'none'}
|
||||
'no-ai': {extent:'none', activities:'', review:'editorial'},
|
||||
research: {extent:'assisted', activities:'research', review:'editorial'},
|
||||
summary: {extent:'assisted', activities:'summarisation', review:'editorial'},
|
||||
full: {extent:'full', activities:'generation', review:'editorial'}
|
||||
};
|
||||
|
||||
function value(id) {
|
||||
@@ -27,6 +25,8 @@
|
||||
image: 'article-image',
|
||||
research: 'article-research',
|
||||
translation: 'article-translation',
|
||||
audio: 'article-audio',
|
||||
video: 'article-video',
|
||||
code: 'article-code'
|
||||
};
|
||||
Object.entries(fields).forEach(([name, id]) => {
|
||||
@@ -35,36 +35,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function legalCategories() {
|
||||
const values = [];
|
||||
if ($('legal-deepfake')?.checked) values.push('deepfake');
|
||||
if ($('legal-public-interest')?.checked) values.push('publicInterestText');
|
||||
if ($('legal-creative')?.checked) values.push('artisticCreativeSatiricalFictional');
|
||||
if ($('legal-other-voluntary')?.checked) values.push('otherVoluntary');
|
||||
return values;
|
||||
}
|
||||
|
||||
function substantiveReview(mode) {
|
||||
return mode === 'editorial' || mode === 'expert';
|
||||
}
|
||||
|
||||
function legalAssessment(locale, articleMode) {
|
||||
const categories = legalCategories();
|
||||
const deepfake = categories.includes('deepfake');
|
||||
const publicInterest = categories.includes('publicInterestText');
|
||||
const creative = categories.includes('artisticCreativeSatiricalFictional');
|
||||
if (categories.length === 0) return {text: locale.text.legal_assessment_none, legal: false, badge: locale.text.badge_article || 'Article transparency'};
|
||||
if (categories.includes('otherVoluntary')) return {text: locale.text.legal_assessment_voluntary, legal: false, badge: locale.text.badge_article || 'Article transparency'};
|
||||
if (deepfake) return {text: creative ? locale.text.legal_assessment_deepfake_creative : locale.text.legal_assessment_deepfake, legal: true, badge: locale.text.badge_legal_disclosure || 'AI-generated / manipulated content'};
|
||||
if (publicInterest) {
|
||||
const review = articleMode ? $('article-text-review').value : ($('component').value === 'text' ? $('review').value : 'none');
|
||||
const hasResponsibility = Boolean(value('responsible'));
|
||||
if (substantiveReview(review) && hasResponsibility) return {text: locale.text.legal_assessment_public_exemption, legal: false, badge: locale.text.badge_article || 'Article transparency'};
|
||||
return {text: locale.text.legal_assessment_public_required, legal: true, badge: locale.text.badge_public_interest || 'AI-generated / edited content'};
|
||||
}
|
||||
return {text: locale.text.legal_assessment_voluntary, legal: false, badge: locale.text.badge_article || 'Article transparency'};
|
||||
}
|
||||
|
||||
function update(applyPreset = false) {
|
||||
const mode = $('declaration-mode').value;
|
||||
const articleMode = mode === 'article';
|
||||
@@ -98,11 +68,17 @@
|
||||
params.set('lang', lang);
|
||||
params.set('assurance', assurance);
|
||||
if ($('subject').value.trim()) params.set('subject', $('subject').value.trim());
|
||||
if (value('responsible-role')) params.set('responsibleRole', value('responsible-role'));
|
||||
|
||||
const boolParam = (id, key) => { const el = $(id); if (el && el.checked) params.set(key, 'true'); };
|
||||
boolParam('public-interest-text', 'publicInterestText');
|
||||
boolParam('deepfake', 'deepfake');
|
||||
boolParam('artistic-context', 'artisticCreativeSatiricalFictional');
|
||||
boolParam('substantial-review', 'substantialHumanReview');
|
||||
boolParam('editorial-responsibility-confirmed', 'editorialResponsibilityConfirmed');
|
||||
boolParam('first-exposure-disclosure', 'firstExposureDisclosure');
|
||||
boolParam('accessibility-considered', 'accessibilityConsidered');
|
||||
if (value('responsible')) params.set('responsible', value('responsible'));
|
||||
if (value('responsible-url')) params.set('responsibleUrl', value('responsible-url'));
|
||||
const legal = legalCategories();
|
||||
if (legal.length) params.set('legalContext', legal.join(','));
|
||||
|
||||
if (cfg.capabilities.custom_text) {
|
||||
if (value('custom-title')) params.set('customTitle', value('custom-title'));
|
||||
@@ -121,28 +97,17 @@
|
||||
badgeParams.set('link', declarationURL);
|
||||
const badgeURL = `${base}/v1/badge.svg?${badgeParams}`;
|
||||
const manifestURL = `${base}/v1/declaration.json?${params}`;
|
||||
const assessment = legalAssessment(locale, articleMode);
|
||||
const alt = assessment.legal
|
||||
? assessment.badge
|
||||
: (articleMode
|
||||
? (locale.text.badge_article || 'Article transparency')
|
||||
: ((locale.presets[preset] && locale.presets[preset].title) || locale.extents[$('extent').value] || 'AI usage disclosure'));
|
||||
if ($('legal-assessment')) {
|
||||
$('legal-assessment').innerHTML = `<strong>${locale.text.legal_assessment_heading || 'Article 50 assessment'}</strong><span>${assessment.text || ''}</span>`;
|
||||
$('legal-assessment').dataset.level = assessment.legal ? 'attention' : 'info';
|
||||
}
|
||||
let alt = articleMode ? (locale.text.badge_article || 'Article transparency') : (locale.text.ai_label || 'UCNG');
|
||||
if ($('deepfake')?.checked) alt = locale.text.badge_article50_deepfake || alt;
|
||||
else if ($('public-interest-text')?.checked && !($('substantial-review')?.checked && $('editorial-responsibility-confirmed')?.checked)) alt = locale.text.badge_article50_text || alt;
|
||||
|
||||
$('preview-badge').src = badgeURL;
|
||||
$('preview-badge').alt = alt;
|
||||
$('preview-link').href = declarationURL;
|
||||
const imageMarkup = `<a href="${declarationURL}"><img src="${badgeURL}" alt="${alt}"></a>`;
|
||||
$('html-code').value = assessment.legal && $('theme').value === 'emoji'
|
||||
? `${imageMarkup} <span>${assessment.badge}</span>`
|
||||
: imageMarkup;
|
||||
$('markdown-code').value = assessment.legal && $('theme').value === 'emoji'
|
||||
? `[](${declarationURL}) **${assessment.badge}**`
|
||||
: `[](${declarationURL})`;
|
||||
$('html-code').value = `<a href="${declarationURL}"><img src="${badgeURL}" alt="${alt}"></a>`;
|
||||
$('markdown-code').value = `[](${declarationURL})`;
|
||||
$('json-code').value = manifestURL;
|
||||
if ($('assessment-code')) $('assessment-code').value = `${base}/v1/article50-assessment.json?${params}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,59 +117,13 @@
|
||||
if (!reviewSelect) return;
|
||||
if (extentSelect.value === 'none') {
|
||||
reviewSelect.value = 'none';
|
||||
} else if (reviewSelect.value === 'none') {
|
||||
reviewSelect.value = 'editorial';
|
||||
}
|
||||
// For AI-assisted/generated components keep "none" until the publisher
|
||||
// explicitly selects the review process that actually took place.
|
||||
update(false);
|
||||
});
|
||||
});
|
||||
|
||||
function alignLegalContextWithContent(changedId) {
|
||||
if (changedId === 'legal-deepfake' && $('legal-deepfake')?.checked) {
|
||||
if ($('legal-other-voluntary')) $('legal-other-voluntary').checked = false;
|
||||
if ($('declaration-mode').value === 'single') {
|
||||
if ($('legal-public-interest')?.checked) {
|
||||
$('declaration-mode').value = 'article';
|
||||
} else if (!['coverImage', 'image', 'audio', 'video'].includes($('component').value)) {
|
||||
$('preset').value = '';
|
||||
$('component').value = 'image';
|
||||
if ($('extent').value === 'none') $('extent').value = 'partial';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changedId === 'legal-public-interest' && $('legal-public-interest')?.checked) {
|
||||
if ($('legal-other-voluntary')) $('legal-other-voluntary').checked = false;
|
||||
if ($('declaration-mode').value === 'single') {
|
||||
if ($('legal-deepfake')?.checked) {
|
||||
$('declaration-mode').value = 'article';
|
||||
} else {
|
||||
$('preset').value = '';
|
||||
$('component').value = 'text';
|
||||
if ($('extent').value === 'none') $('extent').value = 'partial';
|
||||
}
|
||||
}
|
||||
if ($('declaration-mode').value === 'article' && $('article-text').value === 'none') {
|
||||
$('article-text').value = 'partial';
|
||||
}
|
||||
}
|
||||
if (changedId === 'legal-creative' && $('legal-creative')?.checked && $('legal-other-voluntary')) {
|
||||
$('legal-other-voluntary').checked = false;
|
||||
}
|
||||
if ($('declaration-mode').value === 'article' && $('legal-deepfake')?.checked) {
|
||||
const media = ['article-cover-image', 'article-image'];
|
||||
const hasMedia = media.some(id => $(id)?.value !== 'none');
|
||||
if (!hasMedia && $('article-image')) $('article-image').value = 'partial';
|
||||
}
|
||||
}
|
||||
|
||||
['legal-deepfake', 'legal-public-interest', 'legal-creative'].forEach(id => {
|
||||
$(id)?.addEventListener('change', () => alignLegalContextWithContent(id));
|
||||
});
|
||||
$('legal-other-voluntary')?.addEventListener('change', () => {
|
||||
if (!$('legal-other-voluntary').checked) return;
|
||||
['legal-deepfake', 'legal-public-interest', 'legal-creative'].forEach(id => { if ($(id)) $(id).checked = false; });
|
||||
});
|
||||
|
||||
form.addEventListener('input', event => update(event.target.id === 'preset'));
|
||||
form.addEventListener('change', event => update(event.target.id === 'preset'));
|
||||
document.querySelector('.pro-panel')?.addEventListener('input', () => update(false));
|
||||
|
||||
BIN
web/static/brand/favicon.ico
Normal file
BIN
web/static/brand/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
web/static/brand/ucng-icon-192.png
Normal file
BIN
web/static/brand/ucng-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
web/static/brand/ucng-icon-32.png
Normal file
BIN
web/static/brand/ucng-icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
web/static/brand/ucng-icon-512.png
Normal file
BIN
web/static/brand/ucng-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
web/static/brand/ucng-logo.png
Normal file
BIN
web/static/brand/ucng-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
BIN
web/static/brand/ucng-open-graph.png
Normal file
BIN
web/static/brand/ucng-open-graph.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
258
web/static/bulk.js
Normal file
258
web/static/bulk.js
Normal file
@@ -0,0 +1,258 @@
|
||||
(() => {
|
||||
const cfg = window.BULK_CONFIG;
|
||||
if (!cfg) return;
|
||||
const $ = id => document.getElementById(id);
|
||||
const t = (key, fallback = key) => (cfg.texts && cfg.texts[key]) || fallback;
|
||||
let lastResult = null;
|
||||
|
||||
const apiKeyInput = $('bulk-api-key');
|
||||
if (apiKeyInput) {
|
||||
apiKeyInput.value = sessionStorage.getItem('aiDisclosureBulkApiKey') || '';
|
||||
apiKeyInput.addEventListener('input', () => sessionStorage.setItem('aiDisclosureBulkApiKey', apiKeyInput.value));
|
||||
}
|
||||
|
||||
function parseLines() {
|
||||
const raw = ($('bulk-items')?.value || '').split(/\r?\n/).map(v => v.trim()).filter(Boolean);
|
||||
return raw.map((line, index) => {
|
||||
let id = '';
|
||||
let subject = '';
|
||||
if (line.includes('|')) {
|
||||
const [left, ...right] = line.split('|');
|
||||
id = left.trim();
|
||||
subject = right.join('|').trim();
|
||||
} else if (line.includes('\t')) {
|
||||
const [left, ...right] = line.split('\t');
|
||||
id = left.trim();
|
||||
subject = right.join('\t').trim();
|
||||
} else {
|
||||
subject = line;
|
||||
}
|
||||
if (!id) id = `item-${index + 1}`;
|
||||
return {id, subject};
|
||||
});
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const count = parseLines().length;
|
||||
const el = $('bulk-item-count');
|
||||
if (el) {
|
||||
el.textContent = `${count} / ${cfg.maxItems}`;
|
||||
el.classList.toggle('over-limit', count > cfg.maxItems);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeReview(extentId, reviewId) {
|
||||
const extent = $(extentId);
|
||||
const review = $(reviewId);
|
||||
if (!extent || !review) return;
|
||||
if (extent.value === 'none') review.value = 'none';
|
||||
else if (review.value === 'none') review.value = 'editorial';
|
||||
}
|
||||
|
||||
const componentPairs = [
|
||||
['bulk-text-extent', 'bulk-text-review'],
|
||||
['bulk-cover-extent', 'bulk-cover-review'],
|
||||
['bulk-image-extent', 'bulk-image-review'],
|
||||
['bulk-research-extent', 'bulk-research-review'],
|
||||
['bulk-translation-extent', 'bulk-translation-review'],
|
||||
['bulk-code-extent', 'bulk-code-review']
|
||||
];
|
||||
componentPairs.forEach(([extentId, reviewId]) => {
|
||||
$(extentId)?.addEventListener('change', () => normalizeReview(extentId, reviewId));
|
||||
});
|
||||
|
||||
function commonParameters(subject) {
|
||||
const params = {
|
||||
mode: 'article',
|
||||
lang: $('bulk-output-language').value,
|
||||
assurance: $('bulk-assurance').value,
|
||||
textExtent: $('bulk-text-extent').value,
|
||||
textReview: $('bulk-text-review').value,
|
||||
coverImageExtent: $('bulk-cover-extent').value,
|
||||
coverImageReview: $('bulk-cover-review').value,
|
||||
imageExtent: $('bulk-image-extent').value,
|
||||
imageReview: $('bulk-image-review').value,
|
||||
researchExtent: $('bulk-research-extent').value,
|
||||
researchReview: $('bulk-research-review').value,
|
||||
translationExtent: $('bulk-translation-extent').value,
|
||||
translationReview: $('bulk-translation-review').value,
|
||||
codeExtent: $('bulk-code-extent').value,
|
||||
codeReview: $('bulk-code-review').value
|
||||
};
|
||||
if (subject) params.subject = subject;
|
||||
const bools = {
|
||||
'bulk-public-interest': 'publicInterestText',
|
||||
'bulk-deepfake': 'deepfake',
|
||||
'bulk-substantial-review': 'substantialHumanReview',
|
||||
'bulk-editorial-responsibility': 'editorialResponsibilityConfirmed',
|
||||
'bulk-first-exposure': 'firstExposureDisclosure',
|
||||
'bulk-accessibility': 'accessibilityConsidered'
|
||||
};
|
||||
Object.entries(bools).forEach(([id, key]) => { if ($(id)?.checked) params[key] = 'true'; });
|
||||
const responsible = $('bulk-responsible')?.value.trim();
|
||||
const responsibleURL = $('bulk-responsible-url')?.value.trim();
|
||||
if (responsible) params.responsible = responsible;
|
||||
if (responsibleURL) params.responsibleUrl = responsibleURL;
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildVisualRequest() {
|
||||
const items = parseLines();
|
||||
if (!items.length) throw new Error(t('error_no_items', 'Add at least one item.'));
|
||||
if (items.length > cfg.maxItems) throw new Error(t('error_too_many_items', 'The configured item limit has been exceeded.'));
|
||||
return {items: items.map(item => ({id: item.id, parameters: commonParameters(item.subject)}))};
|
||||
}
|
||||
|
||||
function headers() {
|
||||
const h = {'Content-Type': 'application/json'};
|
||||
const key = apiKeyInput?.value.trim();
|
||||
if (cfg.requireAPIKey) {
|
||||
if (!key) throw new Error(t('error_api_key', 'A bulk API key is required.'));
|
||||
h.Authorization = `Bearer ${key}`;
|
||||
} else if (key) {
|
||||
h.Authorization = `Bearer ${key}`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
async function run(request) {
|
||||
const status = $('bulk-run-status');
|
||||
if (status) status.textContent = t('status_running', 'Processing…');
|
||||
try {
|
||||
const response = await fetch(cfg.endpoint, {method: 'POST', headers: headers(), body: JSON.stringify(request)});
|
||||
const data = await response.json().catch(() => ({code: 'invalid_response', detail: response.statusText}));
|
||||
if (!response.ok) throw new Error(data.detail || data.title || `${response.status} ${response.statusText}`);
|
||||
lastResult = data;
|
||||
renderResults(data);
|
||||
if (status) status.textContent = t('status_done', 'Completed.');
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `${t('status_error', 'Error')}: ${err.message}`;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
const section = $('results');
|
||||
const body = $('bulk-result-body');
|
||||
const raw = $('bulk-result-json');
|
||||
if (!section || !body || !raw) return;
|
||||
section.hidden = false;
|
||||
body.textContent = '';
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
const valid = items.filter(item => item.valid).length;
|
||||
const failed = items.length - valid;
|
||||
$('bulk-result-summary').textContent = `${t('summary_processed', 'Processed')}: ${items.length} · ${t('summary_successful', 'Successful')}: ${valid} · ${t('summary_failed', 'Failed')}: ${failed}`;
|
||||
|
||||
items.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
const id = document.createElement('td');
|
||||
id.textContent = item.id || '—';
|
||||
const status = document.createElement('td');
|
||||
const pill = document.createElement('span');
|
||||
pill.className = `bulk-result-pill ${item.valid ? 'ok' : 'error'}`;
|
||||
pill.textContent = item.valid ? t('result_ok', 'OK') : t('result_error', 'Error');
|
||||
status.appendChild(pill);
|
||||
if (!item.valid && item.error?.detail) {
|
||||
const detail = document.createElement('small');
|
||||
detail.className = 'bulk-error-detail';
|
||||
detail.textContent = item.error.detail;
|
||||
status.appendChild(detail);
|
||||
}
|
||||
const assessment = document.createElement('td');
|
||||
assessment.textContent = item.article50Assessment?.code || '—';
|
||||
const links = document.createElement('td');
|
||||
if (item.valid) {
|
||||
const linkData = [
|
||||
[t('link_declaration', 'Declaration'), item.declarationUrl],
|
||||
[t('link_manifest', 'Manifest'), item.manifestUrl],
|
||||
[t('link_badge', 'Badge'), item.badgeUrl]
|
||||
];
|
||||
linkData.forEach(([label, href], index) => {
|
||||
if (!href) return;
|
||||
if (index) links.appendChild(document.createTextNode(' · '));
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.textContent = label;
|
||||
links.appendChild(a);
|
||||
});
|
||||
} else links.textContent = '—';
|
||||
tr.append(id, status, assessment, links);
|
||||
body.appendChild(tr);
|
||||
});
|
||||
raw.textContent = JSON.stringify(data, null, 2);
|
||||
section.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||
}
|
||||
|
||||
function download(filename, content, type) {
|
||||
const blob = new Blob([content], {type});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
const s = String(value ?? '');
|
||||
return /[",\n\r;]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
||||
}
|
||||
|
||||
function resultCSV() {
|
||||
const rows = [['id', 'valid', 'assessment', 'declaration_url', 'manifest_url', 'badge_url', 'error']];
|
||||
(lastResult?.items || []).forEach(item => rows.push([
|
||||
item.id || '', item.valid ? 'true' : 'false', item.article50Assessment?.code || '', item.declarationUrl || '', item.manifestUrl || '', item.badgeUrl || '', item.error?.detail || ''
|
||||
]));
|
||||
return rows.map(row => row.map(csvEscape).join(';')).join('\n');
|
||||
}
|
||||
|
||||
$('bulk-items')?.addEventListener('input', updateCount);
|
||||
$('bulk-sample-items')?.addEventListener('click', () => {
|
||||
$('bulk-items').value = 'article-1001 | https://example.org/articles/1001\narticle-1002 | https://example.org/articles/1002\narticle-1003 | https://example.org/articles/1003';
|
||||
updateCount();
|
||||
});
|
||||
$('bulk-run')?.addEventListener('click', async () => {
|
||||
try { await run(buildVisualRequest()); } catch (_) { /* status already rendered */ }
|
||||
});
|
||||
$('bulk-json-sample')?.addEventListener('click', () => {
|
||||
try {
|
||||
if (!parseLines().length) {
|
||||
$('bulk-items').value = 'article-1001 | https://example.org/articles/1001';
|
||||
updateCount();
|
||||
}
|
||||
$('bulk-json').value = JSON.stringify(buildVisualRequest(), null, 2);
|
||||
} catch (err) {
|
||||
$('bulk-run-status').textContent = `${t('status_error', 'Error')}: ${err.message}`;
|
||||
}
|
||||
});
|
||||
$('bulk-json-run')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const request = JSON.parse($('bulk-json').value);
|
||||
await run(request);
|
||||
} catch (err) {
|
||||
$('bulk-run-status').textContent = `${t('status_error', 'Error')}: ${err.message}`;
|
||||
}
|
||||
});
|
||||
$('bulk-copy-json')?.addEventListener('click', async () => {
|
||||
if (!lastResult) return;
|
||||
await navigator.clipboard.writeText(JSON.stringify(lastResult, null, 2));
|
||||
});
|
||||
$('bulk-download-json')?.addEventListener('click', () => {
|
||||
if (lastResult) download('ai-disclosure-bulk-results.json', JSON.stringify(lastResult, null, 2), 'application/json');
|
||||
});
|
||||
$('bulk-download-csv')?.addEventListener('click', () => {
|
||||
if (lastResult) download('ai-disclosure-bulk-results.csv', resultCSV(), 'text/csv;charset=utf-8');
|
||||
});
|
||||
$('bulk-language')?.addEventListener('change', event => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('lang', event.target.value);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
updateCount();
|
||||
componentPairs.forEach(([extentId, reviewId]) => normalizeReview(extentId, reviewId));
|
||||
})();
|
||||
@@ -7,13 +7,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-url-switch]').forEach(select => {
|
||||
select.addEventListener('change', event => {
|
||||
const target = event.target.value;
|
||||
if (target) window.location.assign(target);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.copy-code').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const target = document.getElementById(button.dataset.copyTarget);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,11 +9,22 @@
|
||||
<link rel="canonical" href="{{.BaseURL}}/background?lang={{.Lang}}">
|
||||
{{range .LanguageLinks}}<link rel="alternate" hreflang="{{.Code}}" href="{{.AbsoluteURL}}">{{end}}
|
||||
<link rel="alternate" hreflang="x-default" href="{{.DefaultLanguageURL}}">
|
||||
{{if not .WhiteLabel}}
|
||||
<meta name="theme-color" content="#F8FAFC">
|
||||
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{{end}}
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="background-page">
|
||||
<header class="site-header background-header">
|
||||
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a>
|
||||
<a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
|
||||
<nav>
|
||||
<a href="/product?lang={{.Lang}}">{{.Background.NavProduct}}</a>
|
||||
<a href="/?lang={{.Lang}}#generator">{{.Background.NavGenerator}}</a>
|
||||
@@ -158,7 +169,7 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><span>{{.Background.Footer}} · App 1.6.3 · Schema 1.2 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<footer><span>{{if not .WhiteLabel}}{{.Background.Footer}}{{end}}</span><span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span></footer>
|
||||
<script src="/static/marketing.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
174
web/templates/bulk.html
Normal file
174
web/templates/bulk.html
Normal file
@@ -0,0 +1,174 @@
|
||||
{{define "bulk.html"}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Name}} · {{index .Text "bulk_title"}}</title>
|
||||
<meta name="description" content="{{index .Text "bulk_meta_description"}}">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
{{if not .WhiteLabel}}
|
||||
<meta name="theme-color" content="#F8FAFC">
|
||||
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{{end}}
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="bulk-page">
|
||||
<header class="site-header bulk-header">
|
||||
<a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
|
||||
<nav>
|
||||
<a href="#workspace">{{index .Text "bulk_nav_workspace"}}</a>
|
||||
<a href="#advanced">{{index .Text "bulk_nav_advanced"}}</a>
|
||||
<a href="#results">{{index .Text "bulk_nav_results"}}</a>
|
||||
</nav>
|
||||
<label class="language-switch"><span class="sr-only">{{index .Text "field_language"}}</span><select id="bulk-language">
|
||||
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select></label>
|
||||
</header>
|
||||
|
||||
<main class="bulk-main">
|
||||
<section class="bulk-hero">
|
||||
<div>
|
||||
<p class="eyebrow">{{index .Text "bulk_eyebrow"}}</p>
|
||||
<h1>{{index .Text "bulk_title"}}</h1>
|
||||
<p class="lead">{{index .Text "bulk_intro"}}</p>
|
||||
</div>
|
||||
<div class="bulk-status-grid" aria-label="{{index .Text "bulk_status"}}">
|
||||
<div class="bulk-status-card"><span>{{index .Text "bulk_license"}}</span><strong>{{if .BulkAPI}}{{index .Text "bulk_active"}}{{else}}{{index .Text "bulk_inactive"}}{{end}}</strong></div>
|
||||
<div class="bulk-status-card"><span>{{index .Text "bulk_limit"}}</span><strong>{{.BulkMaxItems}}</strong></div>
|
||||
<div class="bulk-status-card"><span>{{index .Text "bulk_auth"}}</span><strong>{{if .BulkRequireAPIKey}}{{index .Text "bulk_api_key"}}{{else}}{{index .Text "bulk_no_api_key"}}{{end}}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if not .BulkAPI}}
|
||||
<section class="notice bulk-license-warning">
|
||||
<strong>{{index .Text "bulk_inactive_title"}}</strong>
|
||||
<p>{{index .Text "bulk_inactive_help"}}</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section id="workspace" class="panel bulk-workspace">
|
||||
<div class="section-heading-row bulk-section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">{{index .Text "bulk_workspace_eyebrow"}}</p>
|
||||
<h2>{{index .Text "bulk_workspace_title"}}</h2>
|
||||
<p>{{index .Text "bulk_workspace_intro"}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .BulkRequireAPIKey}}
|
||||
<div class="bulk-auth-box">
|
||||
<label>{{index .Text "bulk_api_key_label"}}
|
||||
<input id="bulk-api-key" type="password" autocomplete="off" placeholder="{{index .Text "bulk_api_key_placeholder"}}">
|
||||
<small>{{index .Text "bulk_api_key_help"}}</small>
|
||||
</label>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="bulk-two-column">
|
||||
<div class="bulk-source-panel">
|
||||
<label>{{index .Text "bulk_items_label"}}
|
||||
<textarea id="bulk-items" rows="12" placeholder="{{index .Text "bulk_items_placeholder"}}"></textarea>
|
||||
<small>{{index .Text "bulk_items_help"}}</small>
|
||||
</label>
|
||||
<div class="bulk-inline-actions">
|
||||
<button type="button" class="button secondary" id="bulk-sample-items">{{index .Text "bulk_sample"}}</button>
|
||||
<span id="bulk-item-count" class="bulk-counter">0 / {{.BulkMaxItems}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bulk-profile-panel">
|
||||
<h3>{{index .Text "bulk_profile_title"}}</h3>
|
||||
<p>{{index .Text "bulk_profile_intro"}}</p>
|
||||
<div class="bulk-profile-grid">
|
||||
<label>{{index .Text "field_language"}}
|
||||
<select id="bulk-output-language">{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}</select>
|
||||
</label>
|
||||
<label>{{index .Text "field_assurance"}}
|
||||
<select id="bulk-assurance">{{range .Assurances}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
|
||||
</label>
|
||||
|
||||
<div class="bulk-component-row"><strong>Text</strong><select id="bulk-text-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-text-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
|
||||
<div class="bulk-component-row"><strong>{{index .Text "bulk_cover_image"}}</strong><select id="bulk-cover-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-cover-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
|
||||
<div class="bulk-component-row"><strong>{{index .Text "bulk_images"}}</strong><select id="bulk-image-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
|
||||
<div class="bulk-component-row"><strong>{{index .Text "bulk_research"}}</strong><select id="bulk-research-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "assisted"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-research-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "editorial"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
|
||||
<div class="bulk-component-row"><strong>{{index .Text "bulk_translation"}}</strong><select id="bulk-translation-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-translation-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
|
||||
<div class="bulk-component-row"><strong>Code</strong><select id="bulk-code-extent">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select><select id="bulk-code-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="bulk-legal-details">
|
||||
<summary>{{index .Text "bulk_article50_title"}}</summary>
|
||||
<div class="legal-check-grid bulk-legal-grid">
|
||||
<label class="check-card"><input id="bulk-public-interest" type="checkbox"><span><strong>{{index .Text "field_public_interest_text"}}</strong></span></label>
|
||||
<label class="check-card"><input id="bulk-deepfake" type="checkbox"><span><strong>{{index .Text "field_deepfake"}}</strong></span></label>
|
||||
<label class="check-card"><input id="bulk-substantial-review" type="checkbox"><span><strong>{{index .Text "field_substantial_review"}}</strong></span></label>
|
||||
<label class="check-card"><input id="bulk-editorial-responsibility" type="checkbox"><span><strong>{{index .Text "field_editorial_responsibility_confirmed"}}</strong></span></label>
|
||||
<label class="check-card"><input id="bulk-first-exposure" type="checkbox"><span><strong>{{index .Text "field_first_exposure"}}</strong></span></label>
|
||||
<label class="check-card"><input id="bulk-accessibility" type="checkbox"><span><strong>{{index .Text "field_accessibility"}}</strong></span></label>
|
||||
</div>
|
||||
<div class="bulk-profile-grid bulk-responsibility-grid">
|
||||
<label>{{index .Text "field_responsible_name"}}<input id="bulk-responsible" type="text"></label>
|
||||
<label>{{index .Text "field_responsible_url"}}<input id="bulk-responsible-url" type="url"></label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="bulk-primary-actions">
|
||||
<button type="button" id="bulk-run" {{if not .BulkAPI}}disabled{{end}}>{{index .Text "bulk_run"}}</button>
|
||||
<span id="bulk-run-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="advanced" class="panel bulk-advanced">
|
||||
<p class="eyebrow">{{index .Text "bulk_advanced_eyebrow"}}</p>
|
||||
<h2>{{index .Text "bulk_advanced_title"}}</h2>
|
||||
<p>{{index .Text "bulk_advanced_intro"}}</p>
|
||||
<label>{{index .Text "bulk_json_label"}}
|
||||
<textarea id="bulk-json" class="bulk-json-editor" rows="16"></textarea>
|
||||
</label>
|
||||
<div class="bulk-inline-actions">
|
||||
<button type="button" class="button secondary" id="bulk-json-sample">{{index .Text "bulk_json_sample"}}</button>
|
||||
<button type="button" id="bulk-json-run" {{if not .BulkAPI}}disabled{{end}}>{{index .Text "bulk_json_run"}}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="results" class="panel bulk-results" hidden>
|
||||
<div class="section-heading-row bulk-section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">{{index .Text "bulk_results_eyebrow"}}</p>
|
||||
<h2>{{index .Text "bulk_results_title"}}</h2>
|
||||
</div>
|
||||
<div class="bulk-inline-actions">
|
||||
<button type="button" class="button secondary" id="bulk-copy-json">{{index .Text "bulk_copy_json"}}</button>
|
||||
<button type="button" class="button secondary" id="bulk-download-json">{{index .Text "bulk_download_json"}}</button>
|
||||
<button type="button" class="button secondary" id="bulk-download-csv">{{index .Text "bulk_download_csv"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bulk-result-summary" class="bulk-result-summary"></div>
|
||||
<div class="bulk-table-wrap">
|
||||
<table class="bulk-result-table">
|
||||
<thead><tr><th>{{index .Text "bulk_col_id"}}</th><th>{{index .Text "bulk_col_status"}}</th><th>{{index .Text "bulk_col_assessment"}}</th><th>{{index .Text "bulk_col_links"}}</th></tr></thead>
|
||||
<tbody id="bulk-result-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="bulk-raw-result"><summary>{{index .Text "bulk_raw_result"}}</summary><pre id="bulk-result-json"></pre></details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>{{if .WhiteLabel}}{{.Name}}{{else}}UCNG{{end}} · {{index .Text "bulk_footer_note"}}</span>
|
||||
<span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span>
|
||||
</footer>
|
||||
<script>window.BULK_CONFIG={{.AppConfig}};</script>
|
||||
<script src="/static/bulk.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -10,11 +10,22 @@
|
||||
<link rel="describedby" type="application/ld+json" href="{{.ManifestURL}}">
|
||||
{{range .LanguageLinks}}<link rel="alternate" hreflang="{{.Code}}" href="{{.AbsoluteURL}}">{{end}}
|
||||
<link rel="alternate" hreflang="x-default" href="{{.DefaultLanguageURL}}">
|
||||
{{if not .WhiteLabel}}
|
||||
<meta name="theme-color" content="#F8FAFC">
|
||||
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{{end}}
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script nonce="{{.CSPNonce}}" type="application/ld+json">{{.JSONLD}}</script>
|
||||
<script type="application/ld+json">{{.JSONLD}}</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header"><a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a><nav><a href="/?lang={{.Lang}}">{{index .Text "nav_generator"}}</a><a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a><a href="{{.ManifestURL}}">JSON-LD</a></nav></header>
|
||||
<header class="site-header"><a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a><nav><a href="/?lang={{.Lang}}">{{index .Text "nav_generator"}}</a><a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a><a href="{{.ManifestURL}}">JSON-LD</a></nav></header>
|
||||
<main class="declaration-page">
|
||||
<a class="back" href="/?lang={{.Lang}}">← {{index .Text "back"}}</a>
|
||||
<article class="declaration-card">
|
||||
@@ -22,7 +33,7 @@
|
||||
<p class="eyebrow">{{index .Text "declaration_eyebrow"}} · Schema {{.Declaration.SchemaVersion}}</p>
|
||||
<form class="declaration-language" method="get" action="/declaration">
|
||||
<label for="declaration-language">{{index .Text "field_language"}}</label>
|
||||
<select id="declaration-language" aria-label="{{index .Text "field_language"}}" data-url-switch>
|
||||
<select id="declaration-language" aria-label="{{index .Text "field_language"}}" onchange="window.location.assign(this.value)">
|
||||
{{range .LanguageLinks}}<option value="{{.URL}}" lang="{{.Code}}"{{if .Current}} selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
<noscript><div class="language-link-list">{{range .LanguageLinks}}<a href="{{.URL}}" lang="{{.Code}}"{{if .Current}} aria-current="page"{{end}}>{{.Name}}</a>{{end}}</div></noscript>
|
||||
@@ -37,6 +48,28 @@
|
||||
<div class="summary-copy">{{range .Summary}}<p>{{.}}</p>{{end}}</div>
|
||||
</section>
|
||||
|
||||
{{if .RegulatoryFacts}}
|
||||
<section class="regulatory-section" aria-labelledby="regulatory-title">
|
||||
<div class="section-heading-row">
|
||||
<div>
|
||||
<p class="eyebrow">{{index .Text "article50_section_title"}}</p>
|
||||
<h2 id="regulatory-title">{{index .Text "assessment_heading"}}</h2>
|
||||
</div>
|
||||
<span class="assessment-status assessment-{{.Assessment.Severity}}">{{.Assessment.Title}}</span>
|
||||
</div>
|
||||
<div class="assessment-card assessment-{{.Assessment.Severity}}">
|
||||
<p class="assessment-summary">{{.Assessment.Summary}}</p>
|
||||
{{if .Assessment.Details}}<ul class="assessment-findings">{{range .Assessment.Details}}<li>{{.}}</li>{{end}}</ul>{{end}}
|
||||
{{if .Assessment.Warnings}}<ul class="assessment-warnings">{{range .Assessment.Warnings}}<li>{{.}}</li>{{end}}</ul>{{end}}
|
||||
<p class="assessment-disclaimer">{{index .Text "assessment_disclaimer"}}</p>
|
||||
</div>
|
||||
<h3>{{index .Text "regulatory_context"}}</h3>
|
||||
<dl class="facts regulatory-facts">
|
||||
{{range .RegulatoryFacts}}<div><dt>{{.Label}}</dt><dd>{{.Value}}</dd></div>{{end}}
|
||||
</dl>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="component-section" aria-labelledby="component-title">
|
||||
<h2 id="component-title">{{index .Text "article_table_heading"}}</h2>
|
||||
<div class="component-table-wrap">
|
||||
@@ -48,23 +81,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .LegalAssessment.Text}}
|
||||
<section class="legal-assessment-section" aria-labelledby="legal-assessment-title">
|
||||
<h2 id="legal-assessment-title">{{.LegalAssessment.Heading}}</h2>
|
||||
<div class="legal-assessment-card {{.LegalAssessment.Level}}">{{.LegalAssessment.Text}}</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<dl class="facts metadata-facts">
|
||||
{{range .Facts}}<div><dt>{{.Label}}</dt><dd>{{if .Link}}<a href="{{.Link}}">{{.Value}}</a>{{else}}{{.Value}}{{end}}</dd></div>{{end}}
|
||||
</dl>
|
||||
<div class="notice"><strong>{{index .Text "transparency_label"}}</strong> {{index .Text "transparency_text"}}</div>
|
||||
<div class="notice"><strong>{{index .Text "review_legal_label"}}</strong> {{index .Text "review_legal_text"}}</div>
|
||||
<div class="notice"><strong>{{index .Text "machine_marking_label"}}</strong> {{index .Text "machine_marking_text"}}</div>
|
||||
</article>
|
||||
</main>
|
||||
<footer><span><a href="{{.ManifestURL}}">{{index .Text "manifest"}}</a> · <a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a></span>{{template "legal-links" .}}</footer>
|
||||
<script src="/static/marketing.js" defer></script>
|
||||
<footer><span><a href="{{.ManifestURL}}">{{index .Text "manifest"}}</a>{{if .ExportBundle}} · <a href="{{.BundleURL}}">{{index .Text "bundle"}}</a>{{end}}</span><span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span></footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -6,11 +6,22 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Name}}</title>
|
||||
<meta name="description" content="{{index .Text "meta_description"}}">
|
||||
{{if not .WhiteLabel}}
|
||||
<meta name="theme-color" content="#F8FAFC">
|
||||
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{{end}}
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a>
|
||||
<a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
|
||||
<nav><a href="/product?lang={{.Lang}}">{{index .Text "nav_product"}}</a><a href="#generator">{{index .Text "nav_generator"}}</a><a href="#api">{{index .Text "nav_api"}}</a><a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a></nav>
|
||||
<label class="language-switch"><span class="sr-only">{{index .Text "field_language"}}</span><select id="page-language">
|
||||
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
@@ -51,7 +62,6 @@
|
||||
</label>
|
||||
<label>{{index .Text "field_review"}}
|
||||
<select id="review" name="review">{{range .Reviews}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
|
||||
<small>{{index .Text "review_help"}}</small>
|
||||
</label>
|
||||
<label class="wide">{{index .Text "field_activities"}}
|
||||
<input id="activities" name="activities" value="research" placeholder="research,summarisation">
|
||||
@@ -61,30 +71,22 @@
|
||||
<fieldset id="article-fields" class="article-builder wide" hidden>
|
||||
<legend>{{index .Text "article_fields"}}</legend>
|
||||
<p>{{index .Text "article_help"}}</p>
|
||||
<p class="form-warning">{{index .Text "review_help"}}</p>
|
||||
<div class="article-component-grid">
|
||||
<div class="article-component"><label>Text<select id="article-text" data-review="article-text-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-text-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "fact_activities"}} / Recherche<select id="article-research" data-review="article-research-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "assisted"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-research-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>Cover / {{index .Text "fact_component"}}<select id="article-cover-image" data-review="article-cover-image-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-cover-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>Images<select id="article-image" data-review="article-image-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "full"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>Translation<select id="article-translation" data-review="article-translation-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-translation-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>Code<select id="article-code" data-review="article-code-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-code-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "component_text"}}<select id="article-text" data-review="article-text-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-text-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "component_research"}}<select id="article-research" data-review="article-research-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "assisted"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-research-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "editorial"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "component_cover_image"}}<select id="article-cover-image" data-review="article-cover-image-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-cover-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "component_images"}}<select id="article-image" data-review="article-image-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "full"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-image-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "editorial"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "component_translation"}}<select id="article-translation" data-review="article-translation-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-translation-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "component_audio"}}<select id="article-audio" data-review="article-audio-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-audio-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "component_video"}}<select id="article-video" data-review="article-video-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-video-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
<div class="article-component"><label>{{index .Text "component_code"}}<select id="article-code" data-review="article-code-review">{{range .Extents}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{index .Text "field_review"}}<select id="article-code-review">{{range .Reviews}}<option value="{{.Value}}" {{if eq .Value "none"}}selected{{end}}>{{.Label}}</option>{{end}}</select></label></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="legal-context-builder wide">
|
||||
<legend>{{index .Text "legal_context_legend"}}</legend>
|
||||
<p>{{index .Text "legal_context_help"}}</p>
|
||||
<label class="check-row"><input id="legal-deepfake" type="checkbox" value="deepfake"><span><strong>{{index .Text "legal_deepfake"}}</strong><small>{{index .Text "legal_deepfake_help"}}</small></span></label>
|
||||
<label class="check-row"><input id="legal-public-interest" type="checkbox" value="publicInterestText"><span><strong>{{index .Text "legal_public_interest"}}</strong><small>{{index .Text "legal_public_interest_help"}}</small></span></label>
|
||||
<label class="check-row"><input id="legal-creative" type="checkbox" value="artisticCreativeSatiricalFictional"><span><strong>{{index .Text "legal_creative"}}</strong><small>{{index .Text "legal_creative_help"}}</small></span></label>
|
||||
<label class="check-row"><input id="legal-other-voluntary" type="checkbox" value="otherVoluntary"><span><strong>{{index .Text "legal_other_voluntary"}}</strong></span></label>
|
||||
<div id="legal-assessment" class="legal-assessment" role="status" aria-live="polite"></div>
|
||||
</fieldset>
|
||||
<label>{{index .Text "field_language"}}
|
||||
<select id="lang" name="lang">{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}</select>
|
||||
</label>
|
||||
<label>{{index .Text "field_theme"}}
|
||||
<select id="theme" name="theme"><option value="mono">{{index .Text "theme_mono"}}</option><option value="color">{{index .Text "theme_color"}}</option><option value="emoji">{{index .Text "theme_emoji"}}</option></select>
|
||||
<select id="theme" name="theme"><option value="mono">{{index .Text "theme_mono"}}</option><option value="color">{{index .Text "theme_color"}}</option></select>
|
||||
</label>
|
||||
<label class="wide">{{index .Text "field_assurance"}}
|
||||
<select id="assurance" name="assurance">{{range .Assurances}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
|
||||
@@ -93,20 +95,24 @@
|
||||
<label class="wide">{{index .Text "field_subject"}}
|
||||
<input id="subject" name="subject" type="url" placeholder="https://example.org/article">
|
||||
</label>
|
||||
<label class="wide">{{index .Text "field_responsibility_role"}}
|
||||
<select id="responsible-role" name="responsibleRole">
|
||||
<option value="">{{index .Text "responsibility_none"}}</option>
|
||||
<option value="publisher">{{index .Text "responsibility_publisher"}}</option>
|
||||
<option value="other">{{index .Text "responsibility_other"}}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>{{index .Text "field_responsible"}}
|
||||
<input id="responsible" name="responsible" maxlength="200" placeholder="{{index .Text "responsible_placeholder"}}">
|
||||
</label>
|
||||
<label>{{index .Text "field_responsible_url"}}
|
||||
<input id="responsible-url" name="responsibleUrl" type="url" placeholder="https://example.org/impressum">
|
||||
</label>
|
||||
<p class="form-warning wide">{{index .Text "review_legal_text"}}</p>
|
||||
|
||||
<fieldset class="legal-context-builder wide">
|
||||
<legend>{{index .Text "article50_section_title"}}</legend>
|
||||
<p class="fieldset-intro">{{index .Text "article50_section_intro"}}</p>
|
||||
<div class="legal-check-grid">
|
||||
<label class="check-card"><input id="public-interest-text" type="checkbox"><span><strong>{{index .Text "field_public_interest_text"}}</strong><small>{{index .Text "help_public_interest_text"}}</small></span></label>
|
||||
<label class="check-card"><input id="deepfake" type="checkbox"><span><strong>{{index .Text "field_deepfake"}}</strong><small>{{index .Text "help_deepfake"}}</small></span></label>
|
||||
<label class="check-card"><input id="artistic-context" type="checkbox"><span><strong>{{index .Text "field_artistic_context"}}</strong><small>{{index .Text "help_artistic_context"}}</small></span></label>
|
||||
<label class="check-card"><input id="substantial-review" type="checkbox"><span><strong>{{index .Text "field_substantial_review"}}</strong><small>{{index .Text "help_substantial_review"}}</small></span></label>
|
||||
<label class="check-card"><input id="editorial-responsibility-confirmed" type="checkbox"><span><strong>{{index .Text "field_editorial_responsibility_confirmed"}}</strong><small>{{index .Text "help_editorial_responsibility_confirmed"}}</small></span></label>
|
||||
<label class="check-card"><input id="first-exposure-disclosure" type="checkbox"><span><strong>{{index .Text "field_first_exposure"}}</strong></span></label>
|
||||
<label class="check-card"><input id="accessibility-considered" type="checkbox"><span><strong>{{index .Text "field_accessibility"}}</strong></span></label>
|
||||
</div>
|
||||
<div class="form-subgrid legal-responsibility-fields">
|
||||
<label>{{index .Text "field_responsible_name"}}<input id="responsible" maxlength="200"></label>
|
||||
<label>{{index .Text "field_responsible_url"}}<input id="responsible-url" type="url" placeholder="https://example.org/imprint"></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
{{if or .CustomText .CustomBadge}}
|
||||
@@ -146,11 +152,9 @@
|
||||
<label>HTML<textarea id="html-code" readonly></textarea></label>
|
||||
<label>Markdown<textarea id="markdown-code" readonly></textarea></label>
|
||||
<label>JSON-LD URL<textarea id="json-code" readonly></textarea></label>
|
||||
<label>{{index .Text "regulatory_api_label"}}<textarea id="assessment-code" readonly></textarea></label>
|
||||
</div>
|
||||
<button id="copy-html" type="button">{{index .Text "copy_html"}}</button>
|
||||
<div class="notice compliance-notice"><strong>{{index .Text "ai_act_embed_label"}}</strong> {{index .Text "ai_act_embed_text"}}</div>
|
||||
<div class="notice compliance-notice"><strong>{{index .Text "ai_act_machine_label"}}</strong> {{index .Text "ai_act_machine_text"}}</div>
|
||||
<div class="notice compliance-notice"><strong>{{index .Text "emoji_compliance_label"}}</strong> {{index .Text "emoji_compliance_text"}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -160,8 +164,8 @@
|
||||
<article><p class="eyebrow">HA</p><h2>{{index .Text "api_stateless_title"}}</h2><p>{{index .Text "api_stateless_desc"}}</p></article>
|
||||
</section>
|
||||
</main>
|
||||
<footer><span>AI Usage Disclosure · App 1.6.3 · Schema 1.2 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<script nonce="{{.CSPNonce}}">window.APP_CONFIG={{.AppConfig}};</script>
|
||||
<footer><span>{{if not .WhiteLabel}}UCNG · {{end}}App 2.0.2 · Schema 1.2 · {{.License.Edition}}</span><span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span></footer>
|
||||
<script>window.APP_CONFIG={{.AppConfig}};</script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{{define "legal-links"}}
|
||||
<nav class="footer-legal" aria-label="Legal">
|
||||
<a href="/impressum?lang={{.Lang}}">{{.LegalNav.Imprint}}</a>
|
||||
<a href="/datenschutz?lang={{.Lang}}">{{.LegalNav.Privacy}}</a>
|
||||
<a href="/barrierefreiheit?lang={{.Lang}}">{{.LegalNav.Accessibility}}</a>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{define "legal.html"}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Legal.Title}} · {{.Name}}</title>
|
||||
<meta name="description" content="{{.Legal.MetaDescription}}">
|
||||
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="legal-page">
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</a>
|
||||
<nav>
|
||||
<a href="/?lang={{.Lang}}">{{if eq .Lang "de"}}Generator{{else}}Generator{{end}}</a>
|
||||
<a href="/background?lang={{.Lang}}">{{if eq .Lang "de"}}Hintergrund{{else}}Background{{end}}</a>
|
||||
</nav>
|
||||
<div class="legal-language" aria-label="Language">
|
||||
<a href="{{.CanonicalURL}}?lang=de" lang="de">DE</a>
|
||||
<a href="{{.CanonicalURL}}?lang=en" lang="en">EN</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="legal-main">
|
||||
<header class="legal-hero">
|
||||
<p class="eyebrow">{{.Legal.Eyebrow}}</p>
|
||||
<h1>{{.Legal.Title}}</h1>
|
||||
<p class="lead">{{.Legal.Intro}}</p>
|
||||
<p class="legal-updated">{{if eq .Lang "de"}}Stand{{else}}Last updated{{end}}: {{.Legal.LastUpdated}}</p>
|
||||
</header>
|
||||
|
||||
{{if .Legal.Warning}}
|
||||
<aside class="configuration-warning" role="alert">
|
||||
<strong>{{if eq .Lang "de"}}Konfiguration erforderlich{{else}}Configuration required{{end}}</strong>
|
||||
<p>{{.Legal.Warning}}</p>
|
||||
</aside>
|
||||
{{end}}
|
||||
|
||||
<article class="legal-content">
|
||||
{{range .Legal.Sections}}
|
||||
<section class="legal-section">
|
||||
<h2>{{.Title}}</h2>
|
||||
{{range .Paragraphs}}<p>{{.}}</p>{{end}}
|
||||
{{if .Fields}}
|
||||
<dl class="legal-fields">
|
||||
{{range .Fields}}
|
||||
<div><dt>{{.Label}}</dt><dd>{{if .URL}}<a href="{{.URL}}">{{.Value}}</a>{{else}}{{.Value}}{{end}}</dd></div>
|
||||
{{end}}
|
||||
</dl>
|
||||
{{end}}
|
||||
{{if .Bullets}}<ul>{{range .Bullets}}<li>{{.}}</li>{{end}}</ul>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer><span>{{.Name}}</span>{{template "legal-links" .}}</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -7,11 +7,22 @@
|
||||
<title>{{.Name}} · {{.Marketing.NavFeatures}}</title>
|
||||
<meta name="description" content="{{.Marketing.MetaDescription}}">
|
||||
<link rel="canonical" href="{{.BaseURL}}/product?lang={{.Lang}}">
|
||||
{{if not .WhiteLabel}}
|
||||
<meta name="theme-color" content="#F8FAFC">
|
||||
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{{end}}
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="marketing-page">
|
||||
<header class="site-header marketing-header">
|
||||
<a class="brand" href="/product?lang={{.Lang}}">{{.Name}}</a>
|
||||
<a class="brand" href="/product?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
|
||||
<nav>
|
||||
<a href="#features">{{.Marketing.NavFeatures}}</a>
|
||||
<a href="#install">{{.Marketing.NavInstall}}</a>
|
||||
@@ -119,7 +130,7 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><span>{{.Marketing.Footer}} · App 1.6.3 · Schema 1.2 · {{.License.Edition}}</span>{{template "legal-links" .}}</footer>
|
||||
<footer><span>{{if not .WhiteLabel}}{{.Marketing.Footer}}{{end}}</span><span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span></footer>
|
||||
<script src="/static/marketing.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
86
web/templates/operator.html
Normal file
86
web/templates/operator.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{{define "operator.html"}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.LegalPage.Title}} · {{.Name}}</title>
|
||||
<meta name="description" content="{{.LegalPage.Lead}}">
|
||||
<link rel="canonical" href="{{.BaseURL}}/{{.LegalPage.Kind}}?lang={{.Lang}}">
|
||||
{{range .Languages}}<link rel="alternate" hreflang="{{.Code}}" href="{{$.BaseURL}}/{{$.LegalPage.Kind}}?lang={{.Code}}">{{end}}
|
||||
<link rel="alternate" hreflang="x-default" href="{{.BaseURL}}/{{.LegalPage.Kind}}">
|
||||
{{if not .WhiteLabel}}
|
||||
<meta name="theme-color" content="#F8FAFC">
|
||||
<link rel="icon" href="/static/brand/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/ucng-icon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/brand/ucng-icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/static/brand/ucng-icon-192.png">
|
||||
<meta property="og:image" content="{{.BaseURL}}/static/brand/ucng-open-graph.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
{{end}}
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="operator-page">
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/?lang={{.Lang}}">{{if .WhiteLabel}}<span class="brand-text">{{.Name}}</span>{{else}}<img src="/static/brand/ucng-logo.png" width="800" height="233" alt="UCNG — Universal Content Notation Guidelines">{{end}}</a>
|
||||
<nav>
|
||||
<a href="/?lang={{.Lang}}">{{index .Text "nav_generator"}}</a>
|
||||
<a href="/background?lang={{.Lang}}">{{index .Text "nav_background"}}</a>
|
||||
<a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a>
|
||||
<a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a>
|
||||
<a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a>
|
||||
</nav>
|
||||
<label class="language-switch"><span class="sr-only">{{index .Text "field_language"}}</span><select id="operator-language">
|
||||
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select></label>
|
||||
</header>
|
||||
|
||||
<main class="operator-main">
|
||||
<section class="operator-hero">
|
||||
<p class="eyebrow">{{if .WhiteLabel}}{{.Name}}{{else}}UCNG{{end}}</p>
|
||||
<h1>{{.LegalPage.Title}}</h1>
|
||||
<p class="lead">{{.LegalPage.Lead}}</p>
|
||||
</section>
|
||||
|
||||
<div class="operator-grid">
|
||||
{{range .LegalPage.Sections}}
|
||||
<section class="operator-card">
|
||||
<h2>{{.Title}}</h2>
|
||||
{{range .Paragraphs}}<p>{{.}}</p>{{end}}
|
||||
{{if .Items}}
|
||||
<dl class="operator-facts">
|
||||
{{range .Items}}
|
||||
<div><dt>{{.Label}}</dt><dd>{{if .URL}}<a href="{{.URL}}">{{.Value}}</a>{{else}}{{.Value}}{{end}}</dd></div>
|
||||
{{end}}
|
||||
</dl>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<aside class="operator-notice">
|
||||
<strong>{{.LegalPage.NoticeTitle}}</strong>
|
||||
<p>{{.LegalPage.Notice}}</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>{{if not .WhiteLabel}}UCNG · {{end}}App 2.0.2 · Schema 1.2</span>
|
||||
<span><a href="/legal?lang={{.Lang}}">{{legalLabel .Lang "legal"}}</a> · <a href="/privacy?lang={{.Lang}}">{{legalLabel .Lang "privacy"}}</a> · <a href="/accessibility?lang={{.Lang}}">{{legalLabel .Lang "accessibility"}}</a></span>
|
||||
</footer>
|
||||
<script>
|
||||
(() => {
|
||||
const select = document.getElementById('operator-language');
|
||||
if (!select) return;
|
||||
select.addEventListener('change', () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('lang', select.value);
|
||||
window.location.assign(url.toString());
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user