init
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
*.zip
|
||||
*.tar.gz
|
||||
coverage.out
|
||||
bin
|
||||
data
|
||||
.env
|
||||
*.key
|
||||
*.private
|
||||
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
BASE_URL=http://localhost:8080
|
||||
PUBLIC_NAME=AI Usage Disclosure
|
||||
CONTACT_URL=https://b1tsblog.org/page/ai
|
||||
DEFAULT_LANGUAGE=de
|
||||
TRUST_PROXY=true
|
||||
|
||||
# Runtime license issued by the standalone Universal License Platform.
|
||||
# No private keys or configurable public keys belong in this application.
|
||||
LICENSE_TOKEN=
|
||||
LICENSE_MODE=offline
|
||||
LICENSE_SERVER_URL=
|
||||
LICENSE_INSTANCE_ID=
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
LICENSE_REFRESH_INTERVAL=15m
|
||||
LICENSE_REQUEST_TIMEOUT=5s
|
||||
|
||||
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: release-tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_ORG: sendnrw
|
||||
DOCKER_LATEST: latest
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with: # replace it with your local IP
|
||||
config-inline: |
|
||||
[registry."git.send.nrw"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.send.nrw # replace it with your local IP
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
run: |
|
||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/bin/
|
||||
/coverage.out
|
||||
/data/
|
||||
*.log
|
||||
.env
|
||||
*.key
|
||||
*.private
|
||||
.DS_Store
|
||||
105
CHANGELOG.md
Normal file
105
CHANGELOG.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.1
|
||||
|
||||
- Added a generator control for the declaration evidence basis: `selfDeclared`, `technicallyRecorded`, `signed`, and `verified`.
|
||||
- The selected evidence basis is now included in declaration URLs, HTML pages, SVG links, and JSON-LD manifests.
|
||||
- Reworked declaration summaries with more professional, contextual wording and a clear explanation of the selected evidence basis.
|
||||
- Added a scope note for digitally signed declarations: signatures prove origin and integrity, not substantive correctness.
|
||||
- Improved German and English preset descriptions and article-level headings.
|
||||
- Added a full cell grid, fixed column proportions, consistent alignment, and responsive overflow to component tables.
|
||||
- Restyled declaration metadata as a consistent two-column grid.
|
||||
- Documented the assurance parameter in OpenAPI and added regression tests for all supported values.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
- removed `cmd/licensectl`, `cmd/licenseweb`, `cmd/licenseserver` and all embedded license-authority/server code;
|
||||
- removed private-key handling, key generation, token signing, license registry storage and lease signing from the product project;
|
||||
- replaced the previous internal licensing implementation with a verification-only client snapshot compatible with Universal License Platform v1.0.0;
|
||||
- switched online verification to `POST /api/v1/licenses/validate` with legacy `/v1/introspect` fallback;
|
||||
- added support for the platform-signed `verification.serverUrl` claim and well-known discovery;
|
||||
- retained only the build-time public trust store, runtime token verification, feature/limit gates and signed hybrid lease cache;
|
||||
- removed the runtime insecure-Pro environment override so official builds always require a platform-issued token;
|
||||
- removed license-server Docker, Compose, Kubernetes and OpenAPI artifacts from this project;
|
||||
- documented the standalone platform as the sole license issuance and administration authority.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- replaced customer-configurable public keys with a build-time embedded trust store;
|
||||
- added universal `pkg/licensekit` and `pkg/licenseclient` packages;
|
||||
- added product-bound licences with arbitrary features, limits, domains and instance IDs;
|
||||
- added offline, hybrid and online verification modes;
|
||||
- added a central introspection and revocation server with signed short-lived leases;
|
||||
- expanded the local licence web interface for multiple products and optional central registration;
|
||||
- added separate issuer and lease keys with key IDs and rotation support;
|
||||
- removed `LICENSE_PUBLIC_KEY` from application, Docker, Kubernetes and Swarm configuration;
|
||||
- added reference deployment and architecture documentation.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
- `*` is now accepted as a global licensed-domain wildcard.
|
||||
- The license web interface documents the difference between `*` and `*.example.org`.
|
||||
- Added regression coverage for arbitrary public domains and localhost.
|
||||
|
||||
## 1.3.3
|
||||
|
||||
- Changed the default right-hand colour of article-level “Artikeltransparenz” badges from an extent-derived warning colour to a calmer violet (`#7c3aed`).
|
||||
- Individual declaration colours remain unchanged.
|
||||
- Explicit Pro badge colours continue to override the article default.
|
||||
|
||||
## 1.3.2
|
||||
|
||||
- Sprachumschalter direkt auf Deklarationsseiten hinzugefügt.
|
||||
- Beim Sprachwechsel bleiben sämtliche Artikel-, Review-, Assurance- und Pro-Parameter erhalten.
|
||||
- Serverseitig gerenderte Sprachlinks als Fallback ohne JavaScript.
|
||||
- `hreflang`-Links für alle acht Sprachen und `x-default` ergänzt.
|
||||
- Responsive Darstellung des Sprachumschalters innerhalb der Ergebnis-Karte.
|
||||
|
||||
|
||||
## 1.3.1
|
||||
|
||||
- Artikelkomponenten besitzen nun eine eigene Auswahl für die menschliche Prüfung.
|
||||
- Der Generator überträgt die gewählte Prüfart statt immer `editorial` zu setzen.
|
||||
- Komponenten ohne KI erhalten standardmäßig `humanReview: none`.
|
||||
- Zusammenfassungstexte behaupten bei Komponenten ohne KI keine redaktionelle KI-Prüfung mehr.
|
||||
|
||||
|
||||
## 1.3.0
|
||||
|
||||
- Added article-level declarations that combine text, images, research, translation, audio, video and code under one link.
|
||||
- Added automatically generated 5–10 sentence narrative summaries alongside structured component tables.
|
||||
- Added a generator switch between single-use and article/site declarations.
|
||||
- Improved declaration-page typography, responsive headline containment and table layout.
|
||||
- Article declarations remain available through HTML, SVG badge and JSON-LD endpoints.
|
||||
|
||||
|
||||
## 1.2.0
|
||||
|
||||
- Added a responsive marketing page at `/product`.
|
||||
- Added `/pricing` and `/install` section redirects.
|
||||
- Added localized product, feature, edition, pricing, installation and FAQ copy for all eight supported languages.
|
||||
- Added Community/Pro comparison and configurable pricing labels.
|
||||
- Added Docker Compose, Docker CLI, Kubernetes, Docker Swarm and native Go installation instructions with copy controls.
|
||||
- Added environment-variable reference and deployment FAQ.
|
||||
- Added `SALES_URL`, `PRICE_COMMUNITY`, `PRICE_PRO`, `PRICE_PUBLISHER` and `PRICE_AGENCY`.
|
||||
- Added a product/pricing link to the generator navigation.
|
||||
- Added route and language-negotiation tests for the marketing page.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- Added German, English, French, Spanish, Italian, Dutch, Portuguese and Polish language catalogs.
|
||||
- Added `Accept-Language` negotiation and `DEFAULT_LANGUAGE`.
|
||||
- Localized presets, disclosure values, generator UI and declaration pages.
|
||||
- Added optional `presentation` data to schema 1.1 while retaining validator compatibility for legacy 1.0 declarations.
|
||||
- Added Pro capabilities `custom_text`, `custom_badge` and reserved `white_label`.
|
||||
- Added custom declaration titles and descriptions.
|
||||
- Added custom badge labels and six-digit hex colours.
|
||||
- Added offline Ed25519 license verification, expiration and domain restrictions.
|
||||
- Added `cmd/licensectl` for key generation and license signing.
|
||||
- Added `/v1/capabilities`.
|
||||
- Added Kubernetes optional Secret integration and deployment variables for Pro.
|
||||
- Added server-side `403 pro_feature_required` enforcement.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Initial stateless badge, declaration, JSON-LD and validation service.
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY go.mod ./
|
||||
COPY third_party ./third_party
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
COPY web ./web
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/ai-disclosure ./cmd/server
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
COPY --from=build /out/ai-disclosure /ai-disclosure
|
||||
USER 65532:65532
|
||||
EXPOSE 8080
|
||||
ENV LISTEN_ADDRESS=:8080 LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
ENTRYPOINT ["/ai-disclosure"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 AI Usage Disclosure contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
24
Makefile
Normal file
24
Makefile
Normal file
@@ -0,0 +1,24 @@
|
||||
.PHONY: run test test-license-client check build docker-build
|
||||
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
cd third_party/license-platform-client && go test ./...
|
||||
|
||||
test-license-client:
|
||||
cd third_party/license-platform-client && go test ./...
|
||||
|
||||
check:
|
||||
gofmt -w $$(find cmd internal web third_party/license-platform-client -name '*.go' -type f)
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
cd third_party/license-platform-client && go vet ./... && go test -race ./...
|
||||
|
||||
build:
|
||||
mkdir -p bin
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/ai-disclosure ./cmd/server
|
||||
|
||||
docker-build:
|
||||
docker build -t ai-disclosure-standard:1.6.1-local .
|
||||
219
README.md
219
README.md
@@ -1,2 +1,219 @@
|
||||
# ai-disclosure-standard
|
||||
# AI Disclosure Standard 1.6.1
|
||||
|
||||
Ein zustandsloser Go-Dienst für sichtbare und maschinenlesbare Erklärungen zur KI-Nutzung in Artikeln, Webseiten und einzelnen Inhaltsbestandteilen.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- einzelne Deklarationen und zusammengefasste Artikel-/Webseiten-Deklarationen;
|
||||
- SVG-Badges, HTML-Erklärung und JSON-LD unter einem gemeinsamen Link;
|
||||
- Text, Titelbild, weitere Bilder, Recherche, Übersetzung, Audio, Video und Code getrennt erfassbar;
|
||||
- automatisch erzeugte, professionell formulierte Zusammenfassung als Fließtext und strukturierte Tabelle;
|
||||
- frei wählbare Nachweisgrundlage: Selbsterklärung, technisch protokolliert, signiert oder verifiziert;
|
||||
- Deutsch, Englisch, Französisch, Spanisch, Italienisch, Niederländisch, Portugiesisch und Polnisch;
|
||||
- Sprachumschalter direkt auf der Ergebnisseite;
|
||||
- optionale lizenzierte Funktionen für eigene Texte und Badge-Darstellung;
|
||||
- Offline-, Hybrid- und Online-Prüfung über die eigenständige Universal License Platform;
|
||||
- Health-, Readiness- und Prometheus-Endpunkte;
|
||||
- Docker-, Kubernetes- und Docker-Swarm-Deployment.
|
||||
|
||||
## Start unter Windows
|
||||
|
||||
```powershell
|
||||
go run .\cmd\server
|
||||
```
|
||||
|
||||
Danach:
|
||||
|
||||
```text
|
||||
Generator: http://localhost:8080/
|
||||
Produktseite: http://localhost:8080/product
|
||||
Healthcheck: http://localhost:8080/healthz
|
||||
Funktionen: http://localhost:8080/v1/capabilities
|
||||
```
|
||||
|
||||
Go lädt `.env` nicht selbst. Unter PowerShell kann die Datei vor dem Start in die Prozessumgebung übernommen werden oder Docker Compose mit `--env-file .env` verwendet werden.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
docker compose --env-file .env up -d --build
|
||||
```
|
||||
|
||||
## Lizenzprüfung
|
||||
|
||||
Dieses Projekt stellt **keine Lizenzen aus** und enthält keine Schlüsselgenerierung, privaten Schlüssel, Lizenzverwaltung, Admin-Oberfläche oder eigenen Lizenzserver. Diese Aufgaben gehören ausschließlich in die separat betriebene **Universal License Platform**.
|
||||
|
||||
Der Produktserver enthält nur den Laufzeit-Client und akzeptiert:
|
||||
|
||||
```env
|
||||
LICENSE_TOKEN=...
|
||||
LICENSE_MODE=offline
|
||||
LICENSE_SERVER_URL=
|
||||
LICENSE_INSTANCE_ID=
|
||||
```
|
||||
|
||||
Der Produktname ist fest verdrahtet:
|
||||
|
||||
```text
|
||||
ai-disclosure-standard
|
||||
```
|
||||
|
||||
Für die vorhandenen Funktionen verwendet die Plattform diese Feature-IDs:
|
||||
|
||||
```text
|
||||
custom_text
|
||||
custom_badge
|
||||
white_label
|
||||
```
|
||||
|
||||
### Vertrauensschlüssel einbetten
|
||||
|
||||
Die öffentlichen Issuer- und Lease-Schlüssel werden von der Universal License Platform bereitgestellt. Lade dort den Trust Store herunter und ersetze vor dem Build:
|
||||
|
||||
```text
|
||||
internal/app/trusted_keys.json
|
||||
```
|
||||
|
||||
PowerShell-Beispiel:
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest `
|
||||
"https://licenses.example.org/api/v1/trust-store" `
|
||||
-OutFile ".\internal\app\trusted_keys.json"
|
||||
|
||||
go build .\cmd\server
|
||||
```
|
||||
|
||||
Der Trust Store wird mit `go:embed` fest in das Binary eingebaut. Es gibt absichtlich kein `LICENSE_PUBLIC_KEY` und keinen zur Laufzeit austauschbaren Trust Store.
|
||||
|
||||
### Prüfmodi
|
||||
|
||||
**Offline** prüft den signierten Lizenz-Token ausschließlich lokal.
|
||||
|
||||
```env
|
||||
LICENSE_MODE=offline
|
||||
LICENSE_TOKEN=...
|
||||
```
|
||||
|
||||
**Hybrid** fragt die Universal License Platform ab und speichert ein kurzlebiges, signiertes Lease. Bei temporärer Nichterreichbarkeit kann das letzte gültige Lease innerhalb der in der Lizenz festgelegten Grace-Periode verwendet werden.
|
||||
|
||||
```env
|
||||
LICENSE_MODE=hybrid
|
||||
LICENSE_TOKEN=...
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
```
|
||||
|
||||
**Online** benötigt eine erfolgreiche aktuelle Prüfung durch die Plattform.
|
||||
|
||||
```env
|
||||
LICENSE_MODE=online
|
||||
LICENSE_TOKEN=...
|
||||
```
|
||||
|
||||
Bei Hybrid- und Online-Lizenzen übernimmt der Client bevorzugt die von der Plattform signiert in der Lizenz gespeicherte Server-URL. `LICENSE_SERVER_URL` ist nur ein expliziter Override beziehungsweise Fallback.
|
||||
|
||||
Der Client verwendet die Plattform-API:
|
||||
|
||||
```text
|
||||
POST /api/v1/licenses/validate
|
||||
```
|
||||
|
||||
und unterstützt für bestehende Installationen weiterhin:
|
||||
|
||||
```text
|
||||
POST /v1/introspect
|
||||
```
|
||||
|
||||
Weitere Einzelheiten stehen in [`docs/LICENSE-INTEGRATION.md`](docs/LICENSE-INTEGRATION.md). Für bestehende 1.5-Installationen siehe [`docs/MIGRATION-1.5-TO-1.6.md`](docs/MIGRATION-1.5-TO-1.6.md).
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `LISTEN_ADDRESS` | `:8080` | HTTP-Adresse |
|
||||
| `BASE_URL` | `http://localhost:8080` | öffentliche kanonische URL und Domainprüfung |
|
||||
| `PUBLIC_NAME` | `AI Usage Disclosure` | sichtbarer Produktname |
|
||||
| `CONTACT_URL` | Projektseite | Kontakt-/Informationsseite |
|
||||
| `DEFAULT_LANGUAGE` | `de` | Standardsprache |
|
||||
| `TRUST_PROXY` | `false` | Proxy-Header für Client-IP berücksichtigen |
|
||||
| `LICENSE_TOKEN` | leer | von der Universal License Platform ausgestellter Token |
|
||||
| `LICENSE_MODE` | `offline` | Mindestmodus `offline`, `hybrid` oder `online` |
|
||||
| `LICENSE_SERVER_URL` | leer | optionaler Prüfserver-Override |
|
||||
| `LICENSE_INSTANCE_ID` | leer | optionale Instanzbindung |
|
||||
| `LICENSE_CACHE_FILE` | `./data/license-lease.json` | signierter Hybrid-Lease-Cache |
|
||||
| `LICENSE_REFRESH_INTERVAL` | `15m` | Hintergrundaktualisierung |
|
||||
| `LICENSE_REQUEST_TIMEOUT` | `5s` | Timeout der Onlineprüfung |
|
||||
|
||||
## API
|
||||
|
||||
```text
|
||||
GET /badge/{preset}.svg
|
||||
GET /v1/badge.svg
|
||||
GET /declaration
|
||||
GET /v1/declaration.json
|
||||
POST /v1/validate
|
||||
GET /v1/capabilities
|
||||
GET /healthz
|
||||
GET /readyz
|
||||
GET /metrics
|
||||
```
|
||||
|
||||
Beispiel für eine Artikelerklärung:
|
||||
|
||||
```text
|
||||
/declaration?mode=article&textExtent=none&textReview=none&imageExtent=full&imageReview=editorial&researchExtent=assisted&researchReview=expert&assurance=technicallyRecorded&lang=de
|
||||
```
|
||||
|
||||
|
||||
### Nachweisgrundlage
|
||||
|
||||
Der Generator bietet vier interoperable Werte. Sie werden über den Query-Parameter `assurance` an HTML- und JSON-LD-Ausgaben übertragen:
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|---|---|
|
||||
| `selfDeclared` | Die veröffentlichende Person oder Organisation stellt die Angaben selbst bereit. |
|
||||
| `technicallyRecorded` | Die Angaben wurden im Erstellungs- oder Veröffentlichungsprozess technisch protokolliert. |
|
||||
| `signed` | Die Erklärung wurde digital signiert; Herkunft und Unverändertheit können geprüft werden. |
|
||||
| `verified` | Die Angaben wurden nach einem dokumentierten Verfahren zusätzlich verifiziert. |
|
||||
|
||||
Eine digitale Signatur bestätigt die Herkunft und Integrität der Erklärung, nicht automatisch die inhaltliche Richtigkeit ihrer Angaben. Nicht zutreffende Nachweisstufen sollten nicht ausgewählt werden.
|
||||
|
||||
## Entwicklung und Prüfung
|
||||
|
||||
```powershell
|
||||
go test .\...
|
||||
go vet .\...
|
||||
```
|
||||
|
||||
Der eingebundene, reine Laufzeit-Client wird separat geprüft:
|
||||
|
||||
```powershell
|
||||
Set-Location .\third_party\license-platform-client
|
||||
go test .\...
|
||||
```
|
||||
|
||||
Gesamtprüfung über Make:
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
## Projektgrenze
|
||||
|
||||
Im Hauptprojekt verbleiben ausschließlich:
|
||||
|
||||
- ein eingebetteter öffentlicher Trust Store;
|
||||
- ein verifikationsfähiger Client;
|
||||
- Feature- und Limit-Abfragen;
|
||||
- optionaler signierter Lease-Cache.
|
||||
|
||||
Nicht enthalten sind:
|
||||
|
||||
- private Schlüssel;
|
||||
- Keygen oder Lizenzsignierung;
|
||||
- Lizenzportal oder Admin-API;
|
||||
- Lizenzdatenbank;
|
||||
- Widerrufsverwaltung oder Lease-Signierung.
|
||||
|
||||
Diese Funktionen werden nur in der eigenständigen Universal License Platform betrieben.
|
||||
|
||||
16
SECURITY.md
Normal file
16
SECURITY.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Security policy
|
||||
|
||||
Version 1.6.x is the supported line in this project archive.
|
||||
|
||||
## License integration
|
||||
|
||||
- Customer installations configure only `LICENSE_TOKEN` and optional client settings.
|
||||
- `LICENSE_PUBLIC_KEY`, private keys and signing keys are not supported.
|
||||
- Public issuer and lease keys are embedded from `internal/app/trusted_keys.json` at build time.
|
||||
- Key generation, license issuance, token registries, revocation and lease signing exist only in the standalone Universal License Platform.
|
||||
- Use HTTPS for hybrid and online verification.
|
||||
- Protect the hybrid cache directory from other local users; it contains only signed lease tokens, not private keys.
|
||||
|
||||
Online mode fails closed if the platform is unavailable. Hybrid mode may continue only while a previously signed lease remains valid within the grace period encoded in the license.
|
||||
|
||||
Report suspected vulnerabilities privately to the project operator before public disclosure.
|
||||
70
cmd/server/main.go
Normal file
70
cmd/server/main.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "--healthcheck" {
|
||||
url := os.Getenv("HEALTHCHECK_URL")
|
||||
if url == "" {
|
||||
url = "http://127.0.0.1:8080/healthz"
|
||||
}
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
cfg := app.ConfigFromEnv()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
handler, err := app.New(ctx, cfg, logger)
|
||||
if err != nil {
|
||||
logger.Error("application initialization failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.ListenAddress,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Info("server started", "address", cfg.ListenAddress, "base_url", cfg.BaseURL)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info("shutdown requested")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("graceful shutdown failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("server stopped")
|
||||
}
|
||||
38
compose.yaml
Normal file
38
compose.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: ai-disclosure-standard:1.6.1-local
|
||||
environment:
|
||||
BASE_URL: "${BASE_URL:-http://localhost:8080}"
|
||||
PUBLIC_NAME: "${PUBLIC_NAME:-AI Usage Disclosure}"
|
||||
CONTACT_URL: "${CONTACT_URL:-https://b1tsblog.org/page/ai}"
|
||||
DEFAULT_LANGUAGE: "${DEFAULT_LANGUAGE:-de}"
|
||||
TRUST_PROXY: "${TRUST_PROXY:-true}"
|
||||
LICENSE_TOKEN: "${LICENSE_TOKEN:-}"
|
||||
LICENSE_MODE: "${LICENSE_MODE:-offline}"
|
||||
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
|
||||
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-}"
|
||||
LICENSE_CACHE_FILE: /data/license-lease.json
|
||||
LICENSE_REFRESH_INTERVAL: "${LICENSE_REFRESH_INTERVAL:-15m}"
|
||||
LICENSE_REQUEST_TIMEOUT: "${LICENSE_REQUEST_TIMEOUT:-5s}"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- license-cache:/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "/ai-disclosure", "--healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
license-cache:
|
||||
175
deploy/kubernetes.yaml
Normal file
175
deploy/kubernetes.yaml
Normal file
@@ -0,0 +1,175 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ai-disclosure
|
||||
labels:
|
||||
app.kubernetes.io/name: ai-disclosure
|
||||
spec:
|
||||
replicas: 3
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ai-disclosure
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ai-disclosure
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: /metrics
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: app
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.1
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
env:
|
||||
- name: BASE_URL
|
||||
value: https://ai.example.org
|
||||
- name: PUBLIC_NAME
|
||||
value: AI Usage Disclosure
|
||||
- name: CONTACT_URL
|
||||
value: https://b1tsblog.org/page/ai
|
||||
- name: SALES_URL
|
||||
value: https://b1tsblog.org/page/ai
|
||||
- name: DEFAULT_LANGUAGE
|
||||
value: de
|
||||
- name: TRUST_PROXY
|
||||
value: "true"
|
||||
- name: LICENSE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ai-disclosure-license
|
||||
key: token
|
||||
optional: true
|
||||
- name: LICENSE_MODE
|
||||
value: offline
|
||||
- name: LICENSE_SERVER_URL
|
||||
value: ""
|
||||
- name: LICENSE_INSTANCE_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.uid
|
||||
- name: LICENSE_CACHE_FILE
|
||||
value: /data/license-lease.json
|
||||
volumeMounts:
|
||||
- name: license-cache
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 24Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 128Mi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: http
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: license-cache
|
||||
emptyDir: {}
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ai-disclosure
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ai-disclosure
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: ai-disclosure
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: ai-disclosure
|
||||
spec:
|
||||
minAvailable: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ai-disclosure
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: ai-disclosure
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: ai-disclosure
|
||||
minReplicas: 3
|
||||
maxReplicas: 12
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 65
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ai-disclosure
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "15"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "15"
|
||||
nginx.ingress.kubernetes.io/limit-rps: "50"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts: [ai.example.org]
|
||||
secretName: ai-disclosure-tls
|
||||
rules:
|
||||
- host: ai.example.org
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ai-disclosure
|
||||
port:
|
||||
name: http
|
||||
81
deploy/license-server-kubernetes.yaml
Normal file
81
deploy/license-server-kubernetes.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: universal-license-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: universal-license-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: universal-license-server
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: server
|
||||
image: ghcr.io/REPLACE_ME/universal-license-server:1.5.0
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8091
|
||||
env:
|
||||
- name: LICENSE_SERVER_ADDRESS
|
||||
value: :8091
|
||||
- name: LICENSE_SERVER_DATA
|
||||
value: /data/licenses.json
|
||||
- name: LICENSE_TRUST_STORE_FILE
|
||||
value: /config/trusted-keys.json
|
||||
- name: LEASE_SIGNING_KEY_ID
|
||||
value: lease-2026
|
||||
- name: LEASE_SIGNING_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: universal-license-secrets
|
||||
key: lease-private-key
|
||||
- name: LICENSE_SERVER_ADMIN_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: universal-license-secrets
|
||||
key: admin-token
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: trust-store
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: universal-license-data
|
||||
- name: trust-store
|
||||
configMap:
|
||||
name: universal-license-trust-store
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: universal-license-server
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: universal-license-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
70
deploy/swarm-stack.yaml
Normal file
70
deploy/swarm-stack.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.6.1
|
||||
environment:
|
||||
BASE_URL: https://ai.example.org
|
||||
PUBLIC_NAME: AI Usage Disclosure
|
||||
CONTACT_URL: https://b1tsblog.org/page/ai
|
||||
SALES_URL: "${SALES_URL:-https://b1tsblog.org/page/ai}"
|
||||
DEFAULT_LANGUAGE: de
|
||||
TRUST_PROXY: "true"
|
||||
LICENSE_TOKEN: "${LICENSE_TOKEN:-}"
|
||||
LICENSE_MODE: "${LICENSE_MODE:-offline}"
|
||||
LICENSE_SERVER_URL: "${LICENSE_SERVER_URL:-}"
|
||||
LICENSE_INSTANCE_ID: "${LICENSE_INSTANCE_ID:-swarm}"
|
||||
LICENSE_CACHE_FILE: /data/license-lease.json
|
||||
ports:
|
||||
- target: 8080
|
||||
published: 8080
|
||||
protocol: tcp
|
||||
mode: ingress
|
||||
networks:
|
||||
- public
|
||||
volumes:
|
||||
- license-cache:/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test: ["CMD", "/ai-disclosure", "--healthcheck"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 3
|
||||
endpoint_mode: vip
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 5s
|
||||
order: start-first
|
||||
failure_action: rollback
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
order: stop-first
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 3s
|
||||
max_attempts: 5
|
||||
window: 30s
|
||||
placement:
|
||||
preferences:
|
||||
- spread: node.labels.zone
|
||||
resources:
|
||||
reservations:
|
||||
cpus: "0.05"
|
||||
memory: 24M
|
||||
limits:
|
||||
cpus: "0.50"
|
||||
memory: 128M
|
||||
networks:
|
||||
public:
|
||||
driver: overlay
|
||||
attachable: true
|
||||
|
||||
volumes:
|
||||
license-cache:
|
||||
9
docs/COMMERCIAL-BOUNDARY.md
Normal file
9
docs/COMMERCIAL-BOUNDARY.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Commercial boundary
|
||||
|
||||
The declaration renderer and standard remain usable without a commercial entitlement. Licensed capabilities are enabled only by tokens issued through the separately operated Universal License Platform.
|
||||
|
||||
Official binaries embed a public trust store at build time. Customers can configure only a signed `LICENSE_TOKEN` and optional runtime verification settings. They cannot replace the trusted issuer or lease keys through environment variables.
|
||||
|
||||
The AI Disclosure Standard repository does not contain private keys, key generation, license issuance, administration, revocation storage or lease signing. Those responsibilities belong to the standalone platform.
|
||||
|
||||
A technically capable user can still modify open source and compile an unofficial binary. Legal licence terms, trademark protection, official signed releases, support, updates and centrally verified services remain the enforceable commercial boundary.
|
||||
40
docs/LICENSE-CLIENT.md
Normal file
40
docs/LICENSE-CLIENT.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Runtime license client
|
||||
|
||||
The application uses the client protocol from Universal License Platform v1.0.0.
|
||||
|
||||
Imports:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
"github.com/b1tsblog/license-platform/sdk/go/licenseclient"
|
||||
)
|
||||
```
|
||||
|
||||
Initialization is performed in `internal/app/server.go`. The product ID and embedded trust store are not customer-configurable.
|
||||
|
||||
```go
|
||||
licenses := licenseclient.New(ctx, licenseclient.Config{
|
||||
Product: "ai-disclosure-standard",
|
||||
ClientVersion: "1.6.1",
|
||||
Token: cfg.LicenseToken,
|
||||
TrustStore: trustStore,
|
||||
BaseURL: cfg.BaseURL,
|
||||
InstanceID: cfg.LicenseInstanceID,
|
||||
Mode: cfg.LicenseMode,
|
||||
ServerURL: cfg.LicenseServerURL,
|
||||
CacheFile: cfg.LicenseCacheFile,
|
||||
RefreshEvery: cfg.LicenseRefreshEvery,
|
||||
RequestTimeout: cfg.LicenseTimeout,
|
||||
})
|
||||
```
|
||||
|
||||
Feature checks remain server-side:
|
||||
|
||||
```go
|
||||
if licenses.Has("custom_badge") {
|
||||
// accept custom badge presentation
|
||||
}
|
||||
```
|
||||
|
||||
The runtime client contains no license-signing or private-key functionality. See [`LICENSE-INTEGRATION.md`](LICENSE-INTEGRATION.md) for deployment details.
|
||||
172
docs/LICENSE-INTEGRATION.md
Normal file
172
docs/LICENSE-INTEGRATION.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Integration mit der Universal License Platform
|
||||
|
||||
## Verantwortungsgrenze
|
||||
|
||||
Die AI-Disclosure-Anwendung ist ausschließlich ein Lizenz-Client. Die Universal License Platform ist die einzige Autorität für:
|
||||
|
||||
- Schlüsselverwaltung;
|
||||
- Produkterfassung;
|
||||
- Lizenzausstellung;
|
||||
- Token-Registry;
|
||||
- Widerruf und Reaktivierung;
|
||||
- Onlineprüfung;
|
||||
- Signierung kurzlebiger Leases;
|
||||
- Audit- und Benutzerverwaltung.
|
||||
|
||||
Das Hauptprojekt enthält keine dieser Funktionen.
|
||||
|
||||
## Produkt in der Plattform anlegen
|
||||
|
||||
Verwende als unveränderliche Produkt-ID:
|
||||
|
||||
```text
|
||||
ai-disclosure-standard
|
||||
```
|
||||
|
||||
Die aktuell ausgewerteten Feature-IDs sind:
|
||||
|
||||
| Feature | Wirkung |
|
||||
|---|---|
|
||||
| `custom_text` | eigener Titel und eigener Erklärungstext |
|
||||
| `custom_badge` | eigene Badge-Beschriftungen und Farben |
|
||||
| `white_label` | reserviert für markenneutrale Ausgaben |
|
||||
|
||||
Unbekannte Features werden vom Produkt ignoriert und können für andere Produkte weiterverwendet werden.
|
||||
|
||||
## Trust Store installieren
|
||||
|
||||
Die Plattform liefert unter folgendem Endpunkt ausschließlich öffentliche Schlüssel:
|
||||
|
||||
```text
|
||||
GET /api/v1/trust-store
|
||||
```
|
||||
|
||||
Die Antwort muss vor dem Produkt-Build als folgende Datei gespeichert werden:
|
||||
|
||||
```text
|
||||
internal/app/trusted_keys.json
|
||||
```
|
||||
|
||||
Erwartetes Format:
|
||||
|
||||
```json
|
||||
{
|
||||
"licenseKeys": {
|
||||
"issuer-2026": "PUBLIC_KEY_BASE64URL"
|
||||
},
|
||||
"leaseKeys": {
|
||||
"lease-2026": "PUBLIC_KEY_BASE64URL"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Anschließend muss das Produkt neu kompiliert beziehungsweise das Container-Image neu gebaut werden. Der Trust Store ist absichtlich nicht per Umgebungsvariable konfigurierbar.
|
||||
|
||||
## Runtime-Konfiguration
|
||||
|
||||
Minimal für Offline-Lizenzen:
|
||||
|
||||
```env
|
||||
LICENSE_TOKEN=...
|
||||
LICENSE_MODE=offline
|
||||
```
|
||||
|
||||
Hybrid:
|
||||
|
||||
```env
|
||||
LICENSE_TOKEN=...
|
||||
LICENSE_MODE=hybrid
|
||||
LICENSE_CACHE_FILE=/data/license-lease.json
|
||||
LICENSE_REFRESH_INTERVAL=15m
|
||||
LICENSE_REQUEST_TIMEOUT=5s
|
||||
```
|
||||
|
||||
Online:
|
||||
|
||||
```env
|
||||
LICENSE_TOKEN=...
|
||||
LICENSE_MODE=online
|
||||
LICENSE_REQUEST_TIMEOUT=5s
|
||||
```
|
||||
|
||||
Optional:
|
||||
|
||||
```env
|
||||
LICENSE_SERVER_URL=https://licenses.example.org
|
||||
LICENSE_INSTANCE_ID=production-eu-1
|
||||
```
|
||||
|
||||
Die in der Lizenz signierte `verification.serverUrl` wird automatisch erkannt. Die Auflösungsreihenfolge ist:
|
||||
|
||||
1. `licenseclient.Config.ServerURL` beziehungsweise `LICENSE_SERVER_URL`;
|
||||
2. signierte `verification.serverUrl` aus dem Lizenz-Token;
|
||||
3. `/.well-known/license-server` auf der Produkt-Basis-URL.
|
||||
|
||||
## Onlineprotokoll
|
||||
|
||||
Der Client sendet an:
|
||||
|
||||
```text
|
||||
POST /api/v1/licenses/validate
|
||||
```
|
||||
|
||||
Anfrage:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "...",
|
||||
"product": "ai-disclosure-standard",
|
||||
"baseUrl": "https://ai.example.org",
|
||||
"host": "ai.example.org",
|
||||
"instanceId": "production-eu-1",
|
||||
"clientVersion": "1.6.1"
|
||||
}
|
||||
```
|
||||
|
||||
Erwartete Antwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"leaseToken": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Der Lease-Token wird erneut lokal gegen die eingebetteten Lease-Public-Keys geprüft. Eine bloße positive JSON-Antwort ohne gültige Signatur schaltet keine Funktionen frei.
|
||||
|
||||
## Domain- und Instanzbindung
|
||||
|
||||
Die Plattform kann Lizenzen binden an:
|
||||
|
||||
```text
|
||||
example.org
|
||||
*.example.org
|
||||
*
|
||||
```
|
||||
|
||||
`*` erlaubt alle Domains. `*.example.org` erlaubt nur echte Subdomains und nicht automatisch `example.org` selbst.
|
||||
|
||||
Instanz-IDs funktionieren entsprechend. Ist eine Lizenz an Instanzen gebunden, muss `LICENSE_INSTANCE_ID` gesetzt sein.
|
||||
|
||||
## Schlüsselrotation
|
||||
|
||||
1. In der Universal License Platform einen neuen Issuer- oder Lease-Key aktivieren.
|
||||
2. Einen Trust Store herunterladen, der alten und neuen Public Key enthält.
|
||||
3. Das Produkt mit beiden Schlüsseln neu bauen und ausrollen.
|
||||
4. Neue Lizenzen beziehungsweise Leases mit dem neuen Key ausstellen.
|
||||
5. Den alten Public Key erst entfernen, wenn alle damit signierten Tokens abgelaufen oder ersetzt sind.
|
||||
|
||||
## Client-Snapshot
|
||||
|
||||
Unter `third_party/license-platform-client` liegt eine client-only, protokollkompatible Momentaufnahme des Go-SDK aus Universal License Platform v1.0.0. Sie enthält nur:
|
||||
|
||||
- öffentliche Protokolltypen;
|
||||
- Trust-Store-Parsing;
|
||||
- Ed25519-Verifikation;
|
||||
- Kontext-, Domain- und Instanzprüfung;
|
||||
- Offline-/Hybrid-/Online-Client;
|
||||
- signierten Lease-Cache.
|
||||
|
||||
Private-Key-Handling, Signierfunktionen, Admin-Server und Persistenz wurden bewusst nicht übernommen.
|
||||
|
||||
Sobald das eigenständige Modul über einen stabilen Go-Modul-Tag erreichbar ist, kann in `go.mod` der lokale `replace`-Eintrag entfernt und direkt die veröffentlichte SDK-Version verwendet werden.
|
||||
35
docs/MARKETING-PAGE.md
Normal file
35
docs/MARKETING-PAGE.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Marketing page
|
||||
|
||||
The product page is served by the same Go binary as the badge and declaration API.
|
||||
|
||||
## Routes
|
||||
|
||||
- `/product` — complete product, feature, pricing and installation page
|
||||
- `/pricing` — redirects to `/product#pricing`
|
||||
- `/install` — redirects to `/product#install`
|
||||
|
||||
All routes support `?lang=de|en|fr|es|it|nl|pt|pl` and `Accept-Language` negotiation.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
```text
|
||||
SALES_URL=https://example.org/contact
|
||||
PRICE_COMMUNITY=0 €
|
||||
PRICE_PRO=19 €
|
||||
PRICE_PUBLISHER=79 €
|
||||
PRICE_AGENCY=199 €
|
||||
```
|
||||
|
||||
`SALES_URL` is used by paid-plan calls to action. When it is empty, the service falls back to `CONTACT_URL`.
|
||||
|
||||
The page deliberately markets only capabilities that exist in the current build. Domain counts, support levels and commercial terms are offer definitions; adjust them before publication.
|
||||
|
||||
## Editing content
|
||||
|
||||
- Page structure: `web/templates/marketing.html`
|
||||
- Styling: `web/static/style.css`
|
||||
- Copy controls and language switch: `web/static/marketing.js`
|
||||
- Localised product copy: `internal/marketing/content.go`
|
||||
- Generator navigation label: `internal/i18n/marketing.go`
|
||||
|
||||
No external fonts, scripts, images, analytics or cookies are required.
|
||||
40
docs/MIGRATION-1.5-TO-1.6.md
Normal file
40
docs/MIGRATION-1.5-TO-1.6.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Migration von 1.5 auf 1.6
|
||||
|
||||
## Entfernte Bestandteile
|
||||
|
||||
Aus dem AI-Disclosure-Projekt wurden vollständig entfernt:
|
||||
|
||||
- lokale Schlüsselgenerierung und Token-Signierung;
|
||||
- Lizenz-Webinterface;
|
||||
- eingebauter Lizenz- und Widerrufsserver;
|
||||
- Admin-API und lokale Lizenz-Registry;
|
||||
- Lease-Signierung;
|
||||
- zugehörige Docker-, Kubernetes-, OpenAPI- und Schema-Dateien.
|
||||
|
||||
Diese Aufgaben übernimmt ausschließlich die eigenständige Universal License Platform.
|
||||
|
||||
## Umstellung
|
||||
|
||||
1. Universal License Platform v1.0.0 separat starten.
|
||||
2. Vorhandene Issuer- und Lease-Schlüssel entsprechend deren Migrationsanleitung importieren.
|
||||
3. In der Plattform das Produkt `ai-disclosure-standard` und die benötigten Features verwenden.
|
||||
4. Den öffentlichen Trust Store aus `GET /api/v1/trust-store` herunterladen.
|
||||
5. Die Datei als `internal/app/trusted_keys.json` in dieses Projekt kopieren.
|
||||
6. Produkt-Binary oder Container neu bauen.
|
||||
7. Beim Produkt nur noch `LICENSE_TOKEN` und gegebenenfalls Client-Einstellungen setzen.
|
||||
|
||||
## Nicht mehr unterstützte Konfiguration
|
||||
|
||||
Folgende Werte gehören nicht mehr zum Produktprojekt:
|
||||
|
||||
```text
|
||||
LICENSE_PUBLIC_KEY
|
||||
LICENSE_PRIVATE_KEY
|
||||
LEASE_SIGNING_PRIVATE_KEY
|
||||
LEASE_SIGNING_KEY_ID
|
||||
LICENSE_SERVER_ADMIN_TOKEN
|
||||
LICENSE_SERVER_DATA
|
||||
LICENSE_TRUST_STORE_FILE
|
||||
```
|
||||
|
||||
Eine vom alten eingebauten Server verwendete `LICENSE_SERVER_URL` muss auf die neue Universal License Platform zeigen. Neue Hybrid-/Online-Lizenzen können deren öffentliche URL bereits signiert im Token enthalten.
|
||||
10
docs/PRODUCT-PAGE.md
Normal file
10
docs/PRODUCT-PAGE.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Product and installation page
|
||||
|
||||
The public `/product` page documents features and installation paths without prices or upgrade advertising.
|
||||
|
||||
Routes:
|
||||
|
||||
- `/product` — feature and installation overview;
|
||||
- `/install` — redirects to `/product#install`.
|
||||
|
||||
The public page does not expose license administration. Runtime capabilities are available through `/v1/capabilities`; all license creation and management is handled by the separately deployed Universal License Platform.
|
||||
7
go.mod
Normal file
7
go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/b1tsblog/ai-disclosure-standard
|
||||
|
||||
go 1.26
|
||||
|
||||
require github.com/b1tsblog/license-platform v1.0.0
|
||||
|
||||
replace github.com/b1tsblog/license-platform => ./third_party/license-platform-client
|
||||
99
internal/app/config.go
Normal file
99
internal/app/config.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddress string
|
||||
BaseURL string
|
||||
PublicName string
|
||||
ContactURL string
|
||||
SalesURL string
|
||||
DefaultLanguage string
|
||||
TrustProxy bool
|
||||
|
||||
LicenseToken string
|
||||
LicenseMode licensekit.VerificationMode
|
||||
LicenseServerURL string
|
||||
LicenseInstanceID string
|
||||
LicenseCacheFile string
|
||||
LicenseRefreshEvery time.Duration
|
||||
LicenseTimeout time.Duration
|
||||
|
||||
CommunityPrice string
|
||||
ProPrice string
|
||||
PublisherPrice string
|
||||
AgencyPrice string
|
||||
}
|
||||
|
||||
func ConfigFromEnv() Config {
|
||||
contactURL := env("CONTACT_URL", "https://b1tsblog.org/page/ai")
|
||||
mode, err := licensekit.ParseMode(env("LICENSE_MODE", "offline"))
|
||||
if err != nil {
|
||||
mode = licensekit.ModeOffline
|
||||
}
|
||||
return Config{
|
||||
ListenAddress: env("LISTEN_ADDRESS", ":8080"),
|
||||
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
|
||||
PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"),
|
||||
ContactURL: contactURL,
|
||||
SalesURL: env("SALES_URL", contactURL),
|
||||
DefaultLanguage: env("DEFAULT_LANGUAGE", "de"),
|
||||
TrustProxy: strings.EqualFold(env("TRUST_PROXY", "false"), "true"),
|
||||
|
||||
LicenseToken: secretEnv("LICENSE_TOKEN"),
|
||||
LicenseMode: mode,
|
||||
LicenseServerURL: strings.TrimRight(env("LICENSE_SERVER_URL", ""), "/"),
|
||||
LicenseInstanceID: env("LICENSE_INSTANCE_ID", ""),
|
||||
LicenseCacheFile: env("LICENSE_CACHE_FILE", "./data/license-lease.json"),
|
||||
LicenseRefreshEvery: durationEnv("LICENSE_REFRESH_INTERVAL", 15*time.Minute),
|
||||
LicenseTimeout: durationEnv("LICENSE_REQUEST_TIMEOUT", 5*time.Second),
|
||||
|
||||
CommunityPrice: env("PRICE_COMMUNITY", "0 €"),
|
||||
ProPrice: env("PRICE_PRO", "19 €"),
|
||||
PublisherPrice: env("PRICE_PUBLISHER", "79 €"),
|
||||
AgencyPrice: env("PRICE_AGENCY", "199 €"),
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func secretEnv(key string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
path := strings.TrimSpace(os.Getenv(key + "_FILE"))
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func durationEnv(key string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
return duration
|
||||
}
|
||||
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
27
internal/app/licensing.go
Normal file
27
internal/app/licensing.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
const (
|
||||
ProductID = "ai-disclosure-standard"
|
||||
ProductVersion = "1.6.1"
|
||||
FeatureCustomText = "custom_text"
|
||||
FeatureCustomBadge = "custom_badge"
|
||||
FeatureWhiteLabel = "white_label"
|
||||
)
|
||||
|
||||
//go:embed trusted_keys.json
|
||||
var embeddedTrustStore []byte
|
||||
|
||||
func productTrustStore() (licensekit.TrustStore, error) {
|
||||
store, err := licensekit.ParseTrustStore(embeddedTrustStore)
|
||||
if err != nil {
|
||||
return licensekit.TrustStore{}, fmt.Errorf("embedded license trust store: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
42
internal/app/metrics.go
Normal file
42
internal/app/metrics.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type metrics struct {
|
||||
startedAt time.Time
|
||||
requests atomic.Uint64
|
||||
badgeRenders atomic.Uint64
|
||||
validationRequests atomic.Uint64
|
||||
validationFailures atomic.Uint64
|
||||
panics atomic.Uint64
|
||||
}
|
||||
|
||||
func newMetrics() *metrics { return &metrics{startedAt: time.Now()} }
|
||||
|
||||
func (m *metrics) serveHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprintf(w, "# HELP ai_disclosure_uptime_seconds Process uptime in seconds.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_uptime_seconds gauge\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_uptime_seconds %.0f\n", time.Since(m.startedAt).Seconds())
|
||||
fmt.Fprintf(w, "# HELP ai_disclosure_http_requests_total Total HTTP requests.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_http_requests_total counter\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_http_requests_total %d\n", m.requests.Load())
|
||||
fmt.Fprintf(w, "# HELP ai_disclosure_badge_renders_total Total rendered SVG badges.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_badge_renders_total counter\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_badge_renders_total %d\n", m.badgeRenders.Load())
|
||||
fmt.Fprintf(w, "# HELP ai_disclosure_validation_requests_total Total validation requests.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_validation_requests_total counter\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_validation_requests_total %d\n", m.validationRequests.Load())
|
||||
fmt.Fprintf(w, "# HELP ai_disclosure_validation_failures_total Failed validation requests.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_validation_failures_total counter\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_validation_failures_total %d\n", m.validationFailures.Load())
|
||||
fmt.Fprintf(w, "# HELP ai_disclosure_panics_total Recovered handler panics.\n")
|
||||
fmt.Fprintf(w, "# TYPE ai_disclosure_panics_total counter\n")
|
||||
fmt.Fprintf(w, "ai_disclosure_panics_total %d\n", m.panics.Load())
|
||||
}
|
||||
988
internal/app/server.go
Normal file
988
internal/app/server.go
Normal file
@@ -0,0 +1,988 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/badge"
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/declaration"
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/marketing"
|
||||
webassets "github.com/b1tsblog/ai-disclosure-standard/web"
|
||||
"github.com/b1tsblog/license-platform/sdk/go/licenseclient"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
logger *slog.Logger
|
||||
templates *template.Template
|
||||
metrics *metrics
|
||||
licenses *licenseclient.Client
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
type option struct{ Value, Label string }
|
||||
type fact struct{ Label, Value, Link string }
|
||||
type componentRow struct{ Name, Extent, Activities, Review, Note string }
|
||||
type languageLink struct {
|
||||
Code, Name, URL, AbsoluteURL string
|
||||
Current bool
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Name string
|
||||
BaseURL string
|
||||
ContactURL string
|
||||
Lang string
|
||||
Text map[string]string
|
||||
Languages []i18n.LanguageOption
|
||||
Presets []option
|
||||
Components []option
|
||||
Extents []option
|
||||
Reviews []option
|
||||
Assurances []option
|
||||
Declaration declaration.Declaration
|
||||
Title string
|
||||
Description string
|
||||
BadgeURL string
|
||||
ManifestURL string
|
||||
CanonicalURL string
|
||||
JSONLD template.JS
|
||||
AppConfig template.JS
|
||||
Facts []fact
|
||||
ComponentRows []componentRow
|
||||
Summary []string
|
||||
IsArticle bool
|
||||
License licenseclient.Status
|
||||
CustomText bool
|
||||
CustomBadge bool
|
||||
Marketing marketing.Page
|
||||
LanguageLinks []languageLink
|
||||
DefaultLanguageURL string
|
||||
}
|
||||
|
||||
type clientConfig struct {
|
||||
BaseURL string `json:"baseURL"`
|
||||
SelectedLanguage string `json:"selectedLanguage"`
|
||||
Locales map[string]i18n.Locale `json:"locales"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg Config, logger *slog.Logger) (http.Handler, error) {
|
||||
tmpl, err := template.New("root").ParseFS(webassets.Files, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse templates: %w", err)
|
||||
}
|
||||
trustStore, err := productTrustStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
licenses := licenseclient.New(ctx, licenseclient.Config{
|
||||
Product: ProductID, Token: cfg.LicenseToken, TrustStore: trustStore, BaseURL: cfg.BaseURL,
|
||||
InstanceID: cfg.LicenseInstanceID, Mode: cfg.LicenseMode, ServerURL: cfg.LicenseServerURL,
|
||||
CacheFile: cfg.LicenseCacheFile, RefreshEvery: cfg.LicenseRefreshEvery,
|
||||
RequestTimeout: cfg.LicenseTimeout, ClientVersion: ProductVersion,
|
||||
})
|
||||
licenses.Start(ctx)
|
||||
s := &Server{cfg: cfg, logger: logger, templates: tmpl, metrics: newMetrics(), licenses: licenses, mux: http.NewServeMux()}
|
||||
s.routes()
|
||||
return s.middleware(s.mux), nil
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
staticFS, _ := fs.Sub(webassets.Files, "static")
|
||||
fileServer := http.FileServer(http.FS(staticFS))
|
||||
s.mux.HandleFunc("GET /", s.handleIndex)
|
||||
s.mux.HandleFunc("GET /product", s.handleMarketing)
|
||||
s.mux.HandleFunc("GET /install", s.handleMarketingAlias)
|
||||
s.mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(fileServer)))
|
||||
s.mux.HandleFunc("GET /badge/{file}", s.handlePresetBadge)
|
||||
s.mux.HandleFunc("GET /v1/badge.svg", s.handleBadge)
|
||||
s.mux.HandleFunc("GET /declaration", s.handleDeclaration)
|
||||
s.mux.HandleFunc("GET /v1/declaration.json", s.handleManifest)
|
||||
s.mux.HandleFunc("POST /v1/validate", s.handleValidate)
|
||||
s.mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities)
|
||||
s.mux.HandleFunc("GET /schema/v1/declaration.schema.json", s.handleSchema)
|
||||
s.mux.HandleFunc("GET /context/v1", s.handleContext)
|
||||
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /readyz", s.handleReady)
|
||||
s.mux.HandleFunc("GET /metrics", s.metrics.serveHTTP)
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
lang := s.language(r)
|
||||
locale := i18n.Get(lang)
|
||||
cfg := clientConfig{
|
||||
BaseURL: s.cfg.BaseURL, SelectedLanguage: lang, Locales: i18n.ClientCatalogs(),
|
||||
Capabilities: map[string]bool{
|
||||
FeatureCustomText: s.licenses.Has(FeatureCustomText),
|
||||
FeatureCustomBadge: s.licenses.Has(FeatureCustomBadge),
|
||||
},
|
||||
}
|
||||
appJSON, _ := json.Marshal(cfg)
|
||||
data := pageData{
|
||||
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Text: locale.Text,
|
||||
Languages: i18n.Languages(), Presets: presetOptions(locale), Components: orderedOptions(locale.Components, []string{"text", "coverImage", "image", "audio", "video", "code", "other"}),
|
||||
Extents: orderedOptions(locale.Extents, []string{"assisted", "none", "partial", "mostly", "full"}), Reviews: orderedOptions(locale.Reviews, []string{"editorial", "expert", "basic", "none"}),
|
||||
Assurances: orderedOptions(locale.Assurances, []string{"selfDeclared", "technicallyRecorded", "signed", "verified"}),
|
||||
AppConfig: template.JS(appJSON), License: s.licenses.Status(), CustomText: s.licenses.Has(FeatureCustomText), CustomBadge: s.licenses.Has(FeatureCustomBadge),
|
||||
}
|
||||
s.renderHTML(w, "index.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleMarketing(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/product" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
lang := s.language(r)
|
||||
page := marketing.Build(lang, s.cfg.PublicName, s.cfg.BaseURL, s.cfg.SalesURL, s.cfg.ContactURL, marketing.Prices{
|
||||
Community: s.cfg.CommunityPrice,
|
||||
Pro: s.cfg.ProPrice,
|
||||
Publisher: s.cfg.PublisherPrice,
|
||||
Agency: s.cfg.AgencyPrice,
|
||||
})
|
||||
data := pageData{
|
||||
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL,
|
||||
Lang: lang, Languages: i18n.Languages(), Marketing: page, License: s.licenses.Status(),
|
||||
}
|
||||
s.renderHTML(w, "marketing.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleMarketingAlias(w http.ResponseWriter, r *http.Request) {
|
||||
target := "/product"
|
||||
if raw := r.URL.Query().Encode(); raw != "" {
|
||||
target += "?" + raw
|
||||
}
|
||||
if r.URL.Path == "/install" {
|
||||
target += "#install"
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) handlePresetBadge(w http.ResponseWriter, r *http.Request) {
|
||||
file := r.PathValue("file")
|
||||
if !strings.HasSuffix(file, ".svg") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
presetID := strings.TrimSuffix(file, ".svg")
|
||||
if _, ok := declaration.Presets[presetID]; !ok {
|
||||
s.problem(w, http.StatusNotFound, "unknown_preset", "Unknown badge preset.")
|
||||
return
|
||||
}
|
||||
q := cloneValues(r.URL.Query())
|
||||
q.Set("preset", presetID)
|
||||
q.Set("lang", s.languageFromValues(r, q))
|
||||
s.renderBadge(w, r, q)
|
||||
}
|
||||
|
||||
func (s *Server) handleBadge(w http.ResponseWriter, r *http.Request) {
|
||||
q := cloneValues(r.URL.Query())
|
||||
q.Set("lang", s.languageFromValues(r, q))
|
||||
s.renderBadge(w, r, q)
|
||||
}
|
||||
|
||||
func (s *Server) renderBadge(w http.ResponseWriter, r *http.Request, q url.Values) {
|
||||
if customTextRequested(q, "label", "badgeLabel", "message", "badgeMessage") && !s.licenses.Has(FeatureCustomBadge) {
|
||||
s.problem(w, http.StatusForbidden, "pro_feature_required", "Custom badge labels require the Pro feature custom_badge.")
|
||||
return
|
||||
}
|
||||
if customTextRequested(q, "leftColor", "rightColor") && !s.licenses.Has(FeatureCustomBadge) {
|
||||
s.problem(w, http.StatusForbidden, "pro_feature_required", "Custom badge colours require the Pro feature custom_badge.")
|
||||
return
|
||||
}
|
||||
leftColor, rightColor := strings.TrimSpace(q.Get("leftColor")), strings.TrimSpace(q.Get("rightColor"))
|
||||
if (leftColor != "" && !validHexColor(leftColor)) || (rightColor != "" && !validHexColor(rightColor)) {
|
||||
s.problem(w, http.StatusBadRequest, "invalid_colour", "Badge colours must use the form #RRGGBB.")
|
||||
return
|
||||
}
|
||||
extent := strings.TrimSpace(q.Get("extent"))
|
||||
presetID := strings.TrimSpace(q.Get("preset"))
|
||||
isArticle := q.Get("mode") == "article"
|
||||
if isArticle {
|
||||
if d, err := s.declarationFromQuery(q); err == nil {
|
||||
extent = overallExtent(d)
|
||||
}
|
||||
}
|
||||
if p, ok := declaration.Presets[presetID]; ok && extent == "" {
|
||||
extent = p.Extent
|
||||
}
|
||||
if extent == "" {
|
||||
extent = "assisted"
|
||||
}
|
||||
if !declaration.IsValidExtent(extent) {
|
||||
s.problem(w, http.StatusBadRequest, "invalid_extent", "extent must be one of none, assisted, partial, mostly or full")
|
||||
return
|
||||
}
|
||||
locale := i18n.Get(q.Get("lang"))
|
||||
label := locale.Text["ai_label"]
|
||||
message := locale.Extents[extent]
|
||||
if preset, ok := locale.Presets[presetID]; ok {
|
||||
message = preset.Title
|
||||
}
|
||||
if isArticle {
|
||||
message = locale.Text["badge_article"]
|
||||
// Article-level declarations combine several AI-use states. Use a calm
|
||||
// violet as the neutral default instead of inheriting the strongest
|
||||
// component colour (which may be red). Explicit Pro colours still win.
|
||||
if rightColor == "" {
|
||||
rightColor = "#7c3aed"
|
||||
}
|
||||
}
|
||||
if v := firstNonEmpty(q.Get("badgeLabel"), q.Get("label")); v != "" {
|
||||
label = v
|
||||
}
|
||||
if v := firstNonEmpty(q.Get("badgeMessage"), q.Get("message")); v != "" {
|
||||
message = v
|
||||
}
|
||||
link := strings.TrimSpace(q.Get("link"))
|
||||
if link == "auto" {
|
||||
copy := cloneValues(q)
|
||||
for _, key := range []string{"link", "label", "message", "style", "theme", "leftColor", "rightColor"} {
|
||||
copy.Del(key)
|
||||
}
|
||||
link = s.cfg.BaseURL + "/declaration?" + copy.Encode()
|
||||
}
|
||||
data, etag := badge.Render(badge.Options{Label: label, Message: message, Extent: extent, Style: q.Get("style"), Theme: q.Get("theme"), Link: link, LeftColor: leftColor, RightColor: rightColor})
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
s.metrics.badgeRenders.Add(1)
|
||||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeclaration(w http.ResponseWriter, r *http.Request) {
|
||||
q := cloneValues(r.URL.Query())
|
||||
q.Set("lang", s.languageFromValues(r, q))
|
||||
d, err := s.declarationFromQuery(q)
|
||||
if err != nil {
|
||||
s.declarationError(w, err)
|
||||
return
|
||||
}
|
||||
locale := i18n.Get(d.Language)
|
||||
presetText := locale.Presets[q.Get("preset")]
|
||||
title, description := presetText.Title, presetText.Description
|
||||
if title == "" {
|
||||
for _, c := range d.Components {
|
||||
title = locale.Extents[c.AIExtent]
|
||||
break
|
||||
}
|
||||
description = locale.Text["transparency_text"]
|
||||
}
|
||||
if d.Presentation != nil {
|
||||
if d.Presentation.Title != "" {
|
||||
title = d.Presentation.Title
|
||||
}
|
||||
if d.Presentation.Description != "" {
|
||||
description = d.Presentation.Description
|
||||
}
|
||||
}
|
||||
canonicalURL := s.cfg.BaseURL + "/declaration?" + q.Encode()
|
||||
manifestURL := s.cfg.BaseURL + "/v1/declaration.json?" + q.Encode()
|
||||
badgeQ := cloneValues(q)
|
||||
badgeQ.Set("link", canonicalURL)
|
||||
badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode()
|
||||
jsonLD, _ := json.Marshal(d)
|
||||
isArticle := q.Get("mode") == "article" || len(d.Components) > 1
|
||||
if isArticle && (d.Presentation == nil || d.Presentation.Title == "") {
|
||||
title = locale.Text["article_title"]
|
||||
description = articleShortDescription(d, locale)
|
||||
}
|
||||
languageLinks, defaultLanguageURL := s.declarationLanguageLinks(q, d.Language)
|
||||
data := pageData{
|
||||
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: d.Language, Text: locale.Text, Languages: i18n.Languages(),
|
||||
Declaration: d, Title: title, Description: description, BadgeURL: badgeURL, ManifestURL: manifestURL, CanonicalURL: canonicalURL, JSONLD: template.JS(jsonLD),
|
||||
Facts: declarationFacts(d, locale), ComponentRows: declarationComponentRows(d, locale), Summary: declarationSummary(d, locale), IsArticle: isArticle, License: s.licenses.Status(),
|
||||
LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL,
|
||||
}
|
||||
w.Header().Set("Link", "<"+manifestURL+">; rel=describedby; type=application/ld+json")
|
||||
s.renderHTML(w, "declaration.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) declarationLanguageLinks(q url.Values, current string) ([]languageLink, string) {
|
||||
links := make([]languageLink, 0, len(i18n.Languages()))
|
||||
for _, language := range i18n.Languages() {
|
||||
values := cloneValues(q)
|
||||
values.Set("lang", language.Code)
|
||||
relative := "/declaration?" + values.Encode()
|
||||
links = append(links, languageLink{
|
||||
Code: language.Code, Name: language.Name, URL: relative,
|
||||
AbsoluteURL: s.cfg.BaseURL + relative, Current: language.Code == current,
|
||||
})
|
||||
}
|
||||
defaultValues := cloneValues(q)
|
||||
defaultValues.Del("lang")
|
||||
defaultURL := s.cfg.BaseURL + "/declaration"
|
||||
if encoded := defaultValues.Encode(); encoded != "" {
|
||||
defaultURL += "?" + encoded
|
||||
}
|
||||
return links, defaultURL
|
||||
}
|
||||
|
||||
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
q := cloneValues(r.URL.Query())
|
||||
q.Set("lang", s.languageFromValues(r, q))
|
||||
d, err := s.declarationFromQuery(q)
|
||||
if err != nil {
|
||||
s.declarationError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/ld+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_ = json.NewEncoder(w).Encode(d)
|
||||
}
|
||||
|
||||
func (s *Server) declarationFromQuery(q url.Values) (declaration.Declaration, error) {
|
||||
return declaration.NewFromQueryWithOptions(q, s.cfg.BaseURL+"/context/v1", declaration.ParseOptions{
|
||||
AllowCustomText: s.licenses.Has(FeatureCustomText), AllowCustomBadge: s.licenses.Has(FeatureCustomBadge), DefaultLanguage: s.cfg.DefaultLanguage,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) declarationError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, declaration.ErrCustomTextRequiresPro) || errors.Is(err, declaration.ErrCustomBadgeRequiresPro) {
|
||||
s.problem(w, http.StatusForbidden, "pro_feature_required", err.Error())
|
||||
return
|
||||
}
|
||||
s.problem(w, http.StatusBadRequest, "invalid_declaration", err.Error())
|
||||
}
|
||||
|
||||
func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
|
||||
s.metrics.validationRequests.Add(1)
|
||||
body := http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
defer body.Close()
|
||||
dec := json.NewDecoder(body)
|
||||
dec.DisallowUnknownFields()
|
||||
var d declaration.Declaration
|
||||
if err := dec.Decode(&d); err != nil {
|
||||
s.metrics.validationFailures.Add(1)
|
||||
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if err := ensureEOF(dec); err != nil {
|
||||
s.metrics.validationFailures.Add(1)
|
||||
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
if err := declaration.Validate(d); err != nil {
|
||||
s.metrics.validationFailures.Add(1)
|
||||
s.problem(w, http.StatusUnprocessableEntity, "validation_failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"valid": true, "schemaVersion": d.SchemaVersion})
|
||||
}
|
||||
|
||||
func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{"license": s.licenses.Status(), "supportedLanguages": languageCodes()})
|
||||
}
|
||||
|
||||
func (s *Server) handleSchema(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/schema+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_, _ = w.Write([]byte(strings.ReplaceAll(declarationSchema, "__BASE_URL__", s.cfg.BaseURL)))
|
||||
}
|
||||
|
||||
func (s *Server) handleContext(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/ld+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
_, _ = w.Write([]byte(strings.ReplaceAll(jsonLDContext, "__BASE_URL__", s.cfg.BaseURL)))
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
}
|
||||
func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = io.WriteString(w, "ready\n")
|
||||
}
|
||||
|
||||
func (s *Server) renderHTML(w http.ResponseWriter, name string, data pageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.logger.Error("template render failed", "template", name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) language(r *http.Request) string {
|
||||
return i18n.Resolve(r.URL.Query().Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage)
|
||||
}
|
||||
func (s *Server) languageFromValues(r *http.Request, values url.Values) string {
|
||||
return i18n.Resolve(values.Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage)
|
||||
}
|
||||
|
||||
func (s *Server) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
s.metrics.requests.Add(1)
|
||||
requestID := r.Header.Get("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = randomID()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/schema/") || strings.HasPrefix(r.URL.Path, "/context/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
s.metrics.panics.Add(1)
|
||||
s.logger.Error("handler panic", "request_id", requestID, "panic", recovered, "stack", string(debug.Stack()))
|
||||
http.Error(rw, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
s.logger.Info("request", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "status", rw.status, "bytes", rw.bytes, "duration_ms", time.Since(started).Milliseconds(), "remote", clientIP(r, s.cfg.TrustProxy))
|
||||
}()
|
||||
next.ServeHTTP(rw, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"type": s.cfg.BaseURL + "/problems/" + code, "title": http.StatusText(status), "status": status, "code": code, "detail": detail})
|
||||
}
|
||||
|
||||
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status, bytes int
|
||||
}
|
||||
|
||||
func (w *responseWriter) WriteHeader(status int) {
|
||||
w.status = status
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
func (w *responseWriter) Write(b []byte) (int, error) {
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func cacheStatic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func presetOptions(locale i18n.Locale) []option {
|
||||
ids := []string{"research", "no-ai", "summary", "full"}
|
||||
out := make([]option, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, option{Value: id, Label: locale.Presets[id].Title})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orderedOptions(values map[string]string, order []string) []option {
|
||||
out := make([]option, 0, len(order))
|
||||
for _, key := range order {
|
||||
out = append(out, option{Value: key, Label: values[key]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func declarationFacts(d declaration.Declaration, locale i18n.Locale) []fact {
|
||||
facts := []fact{{Label: locale.Text["fact_assurance"], Value: locale.Assurances[d.Assurance]}}
|
||||
if d.Subject != "" {
|
||||
facts = append(facts, fact{Label: locale.Text["fact_subject"], Value: d.Subject, Link: d.Subject})
|
||||
}
|
||||
if d.EditorialResponsibility != nil && d.EditorialResponsibility.Name != "" {
|
||||
facts = append(facts, fact{Label: locale.Text["fact_responsibility"], Value: d.EditorialResponsibility.Name, Link: d.EditorialResponsibility.URL})
|
||||
}
|
||||
if d.DeclaredAt != "" {
|
||||
facts = append(facts, fact{Label: locale.Text["fact_declared_at"], Value: d.DeclaredAt})
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
func declarationComponentRows(d declaration.Declaration, locale i18n.Locale) []componentRow {
|
||||
order := []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code", "other"}
|
||||
rows := make([]componentRow, 0, len(d.Components))
|
||||
for _, name := range order {
|
||||
component, ok := d.Components[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
activities := make([]string, 0, len(component.Activities))
|
||||
for _, activity := range component.Activities {
|
||||
if translated := locale.Activities[activity]; translated != "" {
|
||||
activities = append(activities, translated)
|
||||
} else {
|
||||
activities = append(activities, activity)
|
||||
}
|
||||
}
|
||||
activityText := locale.Text["not_specified"]
|
||||
if component.AIExtent == "none" {
|
||||
activityText = locale.Text["not_applicable"]
|
||||
}
|
||||
if activityText == "" {
|
||||
activityText = locale.Text["none_value"]
|
||||
}
|
||||
if len(activities) > 0 {
|
||||
activityText = strings.Join(activities, ", ")
|
||||
}
|
||||
rows = append(rows, componentRow{Name: locale.Components[name], Extent: locale.Extents[component.AIExtent], Activities: activityText, Review: locale.Reviews[component.HumanReview], Note: component.Note})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func overallExtent(d declaration.Declaration) string {
|
||||
rank := map[string]int{"none": 0, "assisted": 1, "partial": 2, "mostly": 3, "full": 4}
|
||||
best, bestRank := "none", 0
|
||||
for _, c := range d.Components {
|
||||
if rank[c.AIExtent] > bestRank {
|
||||
best, bestRank = c.AIExtent, rank[c.AIExtent]
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func articleShortDescription(d declaration.Declaration, locale i18n.Locale) string {
|
||||
rows := declarationComponentRows(d, locale)
|
||||
parts := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
parts = append(parts, row.Name+": "+row.Extent)
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func declarationSummary(d declaration.Declaration, locale i18n.Locale) []string {
|
||||
if len(d.Components) == 0 {
|
||||
return nil
|
||||
}
|
||||
intro := map[string]string{
|
||||
"de": "Diese Erklärung dokumentiert nachvollziehbar, in welchen Bereichen künstliche Intelligenz bei der Erstellung und Veröffentlichung des gekennzeichneten Inhalts eingesetzt wurde.",
|
||||
"en": "This declaration documents, in a transparent and traceable form, where artificial intelligence was used in creating and publishing the labelled content.",
|
||||
"fr": "Cette déclaration documente de manière transparente et traçable les domaines dans lesquels l’intelligence artificielle a été utilisée pour créer et publier le contenu identifié.",
|
||||
"es": "Esta declaración documenta de forma transparente y trazable en qué áreas se utilizó inteligencia artificial para crear y publicar el contenido identificado.",
|
||||
"it": "La presente dichiarazione documenta in modo trasparente e tracciabile gli ambiti in cui l’intelligenza artificiale è stata utilizzata per creare e pubblicare il contenuto indicato.",
|
||||
"nl": "Deze verklaring documenteert op transparante en navolgbare wijze op welke onderdelen kunstmatige intelligentie is gebruikt bij het maken en publiceren van de aangeduide inhoud.",
|
||||
"pt": "Esta declaração documenta, de forma transparente e rastreável, as áreas em que a inteligência artificial foi utilizada na criação e publicação do conteúdo identificado.",
|
||||
"pl": "Niniejsza deklaracja w przejrzysty i możliwy do prześledzenia sposób dokumentuje obszary, w których wykorzystano sztuczną inteligencję podczas tworzenia i publikacji oznaczonej treści.",
|
||||
}
|
||||
lang := locale.Code
|
||||
if intro[lang] == "" {
|
||||
lang = "en"
|
||||
}
|
||||
statements := []string{intro[lang]}
|
||||
for _, name := range []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code", "other"} {
|
||||
component, ok := d.Components[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
statements = append(statements, componentSummaryStatement(locale, name, component))
|
||||
}
|
||||
closing := map[string][]string{
|
||||
"de": {
|
||||
"Sämtliche Angaben werden unter einem gemeinsamen Link veröffentlicht und stehen zusätzlich als maschinenlesbares JSON-LD zur Verfügung.",
|
||||
"Die inhaltliche und redaktionelle Verantwortung verbleibt unabhängig vom ausgewiesenen KI-Anteil bei der veröffentlichenden Person oder Organisation.",
|
||||
},
|
||||
"en": {
|
||||
"All information is published under a single link and is also available as machine-readable JSON-LD.",
|
||||
"Responsibility for the content and its editorial publication remains with the publishing person or organisation, irrespective of the stated level of AI involvement.",
|
||||
},
|
||||
"fr": {
|
||||
"Toutes les informations sont publiées sous un lien unique et sont également disponibles au format JSON-LD lisible par machine.",
|
||||
"La responsabilité du contenu et de sa publication éditoriale demeure celle de la personne ou de l’organisation éditrice, indépendamment du niveau d’intervention de l’IA indiqué.",
|
||||
},
|
||||
"es": {
|
||||
"Toda la información se publica mediante un único enlace y también está disponible como JSON-LD legible por máquina.",
|
||||
"La responsabilidad sobre el contenido y su publicación editorial permanece en la persona u organización editora, con independencia del nivel de intervención de la IA indicado.",
|
||||
},
|
||||
"it": {
|
||||
"Tutte le informazioni sono pubblicate tramite un unico collegamento e sono disponibili anche come JSON-LD leggibile automaticamente.",
|
||||
"La responsabilità del contenuto e della sua pubblicazione editoriale resta in capo alla persona o all’organizzazione che lo pubblica, indipendentemente dal livello dichiarato di intervento dell’IA.",
|
||||
},
|
||||
"nl": {
|
||||
"Alle gegevens worden via één gezamenlijke link gepubliceerd en zijn daarnaast beschikbaar als machineleesbare JSON-LD.",
|
||||
"De inhoudelijke en redactionele verantwoordelijkheid blijft, ongeacht de vermelde mate van AI-gebruik, bij de publicerende persoon of organisatie.",
|
||||
},
|
||||
"pt": {
|
||||
"Todas as informações são publicadas através de uma única ligação e estão igualmente disponíveis como JSON-LD legível por máquina.",
|
||||
"A responsabilidade pelo conteúdo e pela sua publicação editorial permanece com a pessoa ou organização responsável pela publicação, independentemente do grau de utilização de IA indicado.",
|
||||
},
|
||||
"pl": {
|
||||
"Wszystkie informacje są publikowane pod jednym wspólnym odsyłaczem i są również dostępne jako maszynowo czytelny JSON-LD.",
|
||||
"Odpowiedzialność za treść i jej redakcyjną publikację pozostaje po stronie publikującej osoby lub organizacji, niezależnie od wskazanego zakresu użycia AI.",
|
||||
},
|
||||
}
|
||||
end := closing[lang]
|
||||
end = append(end, assuranceStatement(d.Assurance, locale))
|
||||
return []string{strings.Join(statements, " "), strings.Join(end, " ")}
|
||||
}
|
||||
|
||||
func componentSummaryStatement(locale i18n.Locale, name string, component declaration.Component) string {
|
||||
componentName := locale.Components[name]
|
||||
if componentName == "" {
|
||||
componentName = name
|
||||
}
|
||||
extent := locale.Extents[component.AIExtent]
|
||||
activities := make([]string, 0, len(component.Activities))
|
||||
for _, activity := range component.Activities {
|
||||
if value := locale.Activities[activity]; value != "" {
|
||||
activities = append(activities, value)
|
||||
} else {
|
||||
activities = append(activities, activity)
|
||||
}
|
||||
}
|
||||
activityText := strings.Join(activities, ", ")
|
||||
review := locale.Reviews[component.HumanReview]
|
||||
|
||||
switch locale.Code {
|
||||
case "de":
|
||||
if component.AIExtent == "none" {
|
||||
if component.HumanReview == "none" {
|
||||
return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt.", componentName)
|
||||
}
|
||||
return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt; ergänzend ist eine %s menschliche Prüfung dokumentiert.", componentName, germanReviewAdjective(component.HumanReview))
|
||||
}
|
||||
purpose := ""
|
||||
if activityText != "" {
|
||||
purpose = " für " + activityText
|
||||
}
|
||||
if component.HumanReview == "none" {
|
||||
return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; eine menschliche Prüfung der KI-bezogenen Ergebnisse ist nicht angegeben.", componentName, germanExtentPhrase(component.AIExtent), purpose)
|
||||
}
|
||||
return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; die KI-bezogenen Ergebnisse wurden %s geprüft.", componentName, germanExtentPhrase(component.AIExtent), purpose, germanReviewAdverb(component.HumanReview))
|
||||
case "en":
|
||||
if component.AIExtent == "none" {
|
||||
if component.HumanReview == "none" {
|
||||
return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName)
|
||||
}
|
||||
return fmt.Sprintf("According to the information provided, no AI was used for “%s”; a %s human review is nevertheless recorded.", componentName, strings.ToLower(review))
|
||||
}
|
||||
purpose := ""
|
||||
if activityText != "" {
|
||||
purpose = " for " + activityText
|
||||
}
|
||||
if component.HumanReview == "none" {
|
||||
return fmt.Sprintf("For “%s”, %s%s is recorded; no human review of the AI-related output is specified.", componentName, strings.ToLower(extent), purpose)
|
||||
}
|
||||
return fmt.Sprintf("For “%s”, %s%s is recorded; the AI-related output underwent %s human review.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
|
||||
default:
|
||||
if component.AIExtent == "none" {
|
||||
return genericNoAIStatement(locale.Code, componentName, component.HumanReview, review)
|
||||
}
|
||||
return genericAIStatement(locale.Code, componentName, extent, activityText, component.HumanReview, review)
|
||||
}
|
||||
}
|
||||
|
||||
func germanExtentPhrase(extent string) string {
|
||||
switch extent {
|
||||
case "assisted":
|
||||
return "eine KI-unterstützte Nutzung"
|
||||
case "partial":
|
||||
return "eine teilweise KI-generierte Erstellung"
|
||||
case "mostly":
|
||||
return "eine überwiegend KI-generierte Erstellung"
|
||||
case "full":
|
||||
return "eine vollständig KI-generierte Erstellung"
|
||||
default:
|
||||
return "eine KI-Nutzung"
|
||||
}
|
||||
}
|
||||
|
||||
func germanReviewAdverb(review string) string {
|
||||
switch review {
|
||||
case "basic":
|
||||
return "grundlegend"
|
||||
case "editorial":
|
||||
return "redaktionell"
|
||||
case "expert":
|
||||
return "fachlich"
|
||||
default:
|
||||
return "menschlich"
|
||||
}
|
||||
}
|
||||
|
||||
func germanReviewAdjective(review string) string {
|
||||
switch review {
|
||||
case "basic":
|
||||
return "grundlegende"
|
||||
case "editorial":
|
||||
return "redaktionelle"
|
||||
case "expert":
|
||||
return "fachliche"
|
||||
default:
|
||||
return "menschliche"
|
||||
}
|
||||
}
|
||||
|
||||
func genericNoAIStatement(lang, componentName, reviewCode, review string) string {
|
||||
withReview := reviewCode != "none"
|
||||
switch lang {
|
||||
case "fr":
|
||||
if withReview {
|
||||
return fmt.Sprintf("Selon les informations fournies, aucune IA n’a été utilisée pour « %s » ; une vérification humaine %s est néanmoins documentée.", componentName, strings.ToLower(review))
|
||||
}
|
||||
return fmt.Sprintf("Selon les informations fournies, aucune IA n’a été utilisée pour « %s ».", componentName)
|
||||
case "es":
|
||||
if withReview {
|
||||
return fmt.Sprintf("Según la información proporcionada, no se utilizó IA en «%s»; no obstante, se documenta una revisión humana %s.", componentName, strings.ToLower(review))
|
||||
}
|
||||
return fmt.Sprintf("Según la información proporcionada, no se utilizó IA en «%s».", componentName)
|
||||
case "it":
|
||||
if withReview {
|
||||
return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata l’IA; è comunque documentata una revisione umana %s.", componentName, strings.ToLower(review))
|
||||
}
|
||||
return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata l’IA.", componentName)
|
||||
case "nl":
|
||||
if withReview {
|
||||
return fmt.Sprintf("Volgens de verstrekte informatie is voor ‘%s’ geen AI gebruikt; er is wel een %s menselijke controle vastgelegd.", componentName, strings.ToLower(review))
|
||||
}
|
||||
return fmt.Sprintf("Volgens de verstrekte informatie is voor ‘%s’ geen AI gebruikt.", componentName)
|
||||
case "pt":
|
||||
if withReview {
|
||||
return fmt.Sprintf("De acordo com as informações fornecidas, não foi utilizada IA em «%s»; está, ainda assim, documentada uma revisão humana %s.", componentName, strings.ToLower(review))
|
||||
}
|
||||
return fmt.Sprintf("De acordo com as informações fornecidas, não foi utilizada IA em «%s».", componentName)
|
||||
case "pl":
|
||||
if withReview {
|
||||
return fmt.Sprintf("Zgodnie z podanymi informacjami w obszarze „%s” nie użyto AI; udokumentowano jednak weryfikację człowieka na poziomie %s.", componentName, review)
|
||||
}
|
||||
return fmt.Sprintf("Zgodnie z podanymi informacjami w obszarze „%s” nie użyto AI.", componentName)
|
||||
default:
|
||||
return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName)
|
||||
}
|
||||
}
|
||||
|
||||
func genericAIStatement(lang, componentName, extent, activities string, reviewCode, review string) string {
|
||||
purpose := activities
|
||||
if purpose == "" {
|
||||
purpose = "—"
|
||||
}
|
||||
switch lang {
|
||||
case "fr":
|
||||
if reviewCode == "none" {
|
||||
return fmt.Sprintf("Pour « %s », l’utilisation déclarée est %s, avec pour finalité %s ; aucune vérification humaine des résultats liés à l’IA n’est indiquée.", componentName, strings.ToLower(extent), purpose)
|
||||
}
|
||||
return fmt.Sprintf("Pour « %s », l’utilisation déclarée est %s, avec pour finalité %s ; les résultats liés à l’IA ont fait l’objet d’une vérification humaine %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
|
||||
case "es":
|
||||
if reviewCode == "none" {
|
||||
return fmt.Sprintf("Para «%s» se declara %s, con la finalidad %s; no se indica una revisión humana de los resultados relacionados con la IA.", componentName, strings.ToLower(extent), purpose)
|
||||
}
|
||||
return fmt.Sprintf("Para «%s» se declara %s, con la finalidad %s; los resultados relacionados con la IA fueron objeto de una revisión humana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
|
||||
case "it":
|
||||
if reviewCode == "none" {
|
||||
return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; non è indicata una revisione umana dei risultati legati all’IA.", componentName, strings.ToLower(extent), purpose)
|
||||
}
|
||||
return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; i risultati legati all’IA sono stati sottoposti a revisione umana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
|
||||
case "nl":
|
||||
if reviewCode == "none" {
|
||||
return fmt.Sprintf("Voor ‘%s’ is %s vastgelegd, met als doel %s; er is geen menselijke controle van de AI-gerelateerde resultaten aangegeven.", componentName, strings.ToLower(extent), purpose)
|
||||
}
|
||||
return fmt.Sprintf("Voor ‘%s’ is %s vastgelegd, met als doel %s; de AI-gerelateerde resultaten hebben een %s menselijke controle ondergaan.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
|
||||
case "pt":
|
||||
if reviewCode == "none" {
|
||||
return fmt.Sprintf("Para «%s» está documentado %s, com a finalidade %s; não é indicada uma revisão humana dos resultados relacionados com IA.", componentName, strings.ToLower(extent), purpose)
|
||||
}
|
||||
return fmt.Sprintf("Para «%s» está documentado %s, com a finalidade %s; os resultados relacionados com IA foram sujeitos a uma revisão humana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
|
||||
case "pl":
|
||||
if reviewCode == "none" {
|
||||
return fmt.Sprintf("Dla obszaru „%s” udokumentowano %s w celu %s; nie wskazano weryfikacji człowieka dla wyników związanych z AI.", componentName, extent, purpose)
|
||||
}
|
||||
return fmt.Sprintf("Dla obszaru „%s” udokumentowano %s w celu %s; wyniki związane z AI poddano weryfikacji człowieka na poziomie %s.", componentName, extent, purpose, review)
|
||||
default:
|
||||
return fmt.Sprintf("For “%s”, %s is recorded.", componentName, strings.ToLower(extent))
|
||||
}
|
||||
}
|
||||
|
||||
func assuranceStatement(assurance string, locale i18n.Locale) string {
|
||||
key := "assurance_" + assurance + "_statement"
|
||||
if value := locale.Text[key]; value != "" {
|
||||
if assurance == "signed" {
|
||||
switch locale.Code {
|
||||
case "de":
|
||||
return value + " Die Signatur bestätigt nicht automatisch die inhaltliche Richtigkeit der Angaben."
|
||||
case "en":
|
||||
return value + " The signature does not by itself confirm the substantive accuracy of the information."
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
if value := locale.Text["assurance_"+assurance+"_description"]; value != "" {
|
||||
return value
|
||||
}
|
||||
return locale.Assurances[assurance]
|
||||
}
|
||||
|
||||
func customTextRequested(q url.Values, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if strings.TrimSpace(q.Get(key)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func validHexColor(value string) bool {
|
||||
if len(value) != 7 || value[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[1:] {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func languageCodes() []string {
|
||||
languages := i18n.Languages()
|
||||
out := make([]string, 0, len(languages))
|
||||
for _, l := range languages {
|
||||
out = append(out, l.Code)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
func cloneValues(in url.Values) url.Values {
|
||||
out := url.Values{}
|
||||
for k, v := range in {
|
||||
out[k] = append([]string(nil), v...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func randomID() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
func clientIP(r *http.Request, trustProxy bool) string {
|
||||
if trustProxy {
|
||||
if x := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); x != "" {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
func ensureEOF(dec *json.Decoder) error {
|
||||
var extra any
|
||||
err := dec.Decode(&extra)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
return errors.New("request body must contain exactly one JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const declarationSchema = `{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "__BASE_URL__/schema/v1/declaration.schema.json",
|
||||
"title": "AI Usage Declaration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["@context", "@type", "schemaVersion", "language", "components", "assurance"],
|
||||
"properties": {
|
||||
"@context": {"type": "string"},
|
||||
"@type": {"const": "AIUsageDeclaration"},
|
||||
"schemaVersion": {"const": "1.1"},
|
||||
"subject": {"type": "string", "format": "uri"},
|
||||
"declaredAt": {"type": "string", "format": "date-time"},
|
||||
"language": {"enum": ["de", "en", "fr", "es", "it", "nl", "pt", "pl"]},
|
||||
"assurance": {"enum": ["selfDeclared", "technicallyRecorded", "signed", "verified"]},
|
||||
"editorialResponsibility": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}}
|
||||
},
|
||||
"presentation": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": {
|
||||
"title": {"type": "string", "maxLength": 120},
|
||||
"description": {"type": "string", "maxLength": 500},
|
||||
"badgeLabel": {"type": "string", "maxLength": 40},
|
||||
"badgeMessage": {"type": "string", "maxLength": 80},
|
||||
"leftColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"},
|
||||
"rightColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}
|
||||
}
|
||||
},
|
||||
"components": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/component"}}
|
||||
},
|
||||
"$defs": {
|
||||
"component": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["aiExtent", "humanReview"],
|
||||
"properties": {
|
||||
"aiExtent": {"enum": ["none", "assisted", "partial", "mostly", "full"]},
|
||||
"activities": {"type": "array", "uniqueItems": true, "items": {"enum": ["research", "summarisation", "drafting", "generation", "translation", "editing", "imageGeneration", "codeGeneration", "transcription", "classification"]}},
|
||||
"humanReview": {"enum": ["none", "basic", "editorial", "expert"]},
|
||||
"note": {"type": "string", "maxLength": 500}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
const jsonLDContext = `{
|
||||
"@context": {
|
||||
"@version": 1.1,
|
||||
"AIUsageDeclaration": "__BASE_URL__/vocab/AIUsageDeclaration",
|
||||
"schemaVersion": "__BASE_URL__/vocab/schemaVersion",
|
||||
"subject": {"@id": "https://schema.org/about", "@type": "@id"},
|
||||
"declaredAt": {"@id": "https://schema.org/dateCreated", "@type": "https://www.w3.org/2001/XMLSchema#dateTime"},
|
||||
"language": "https://schema.org/inLanguage",
|
||||
"components": "__BASE_URL__/vocab/components",
|
||||
"aiExtent": "__BASE_URL__/vocab/aiExtent",
|
||||
"activities": "__BASE_URL__/vocab/activities",
|
||||
"humanReview": "__BASE_URL__/vocab/humanReview",
|
||||
"assurance": "__BASE_URL__/vocab/assurance",
|
||||
"presentation": "__BASE_URL__/vocab/presentation",
|
||||
"editorialResponsibility": "https://schema.org/accountablePerson"
|
||||
}
|
||||
}`
|
||||
223
internal/app/server_test.go
Normal file
223
internal/app/server_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", PublicName: "Test", DefaultLanguage: "de"})
|
||||
}
|
||||
|
||||
func testHandlerConfig(t *testing.T, cfg Config) http.Handler {
|
||||
t.Helper()
|
||||
h, err := New(context.Background(), cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestPresetBadgeFrench(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/badge/research.svg?lang=fr", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") {
|
||||
t.Fatalf("content type %q", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Aide à la recherche") {
|
||||
t.Fatal("missing localized preset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
body := `{"@context":"https://example.org/context/v1","@type":"AIUsageDeclaration","schemaVersion":"1.1","language":"de","components":{"text":{"aiExtent":"assisted","activities":["research"],"humanReview":"editorial"}},"assurance":"selfDeclared"}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/validate", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["valid"] != true {
|
||||
t.Fatalf("unexpected response: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunityRejectsCustomBadge(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?lang=en&badgeMessage=Custom", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "pro_feature_required") {
|
||||
t.Fatalf("unexpected response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/capabilities", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"edition":"community"`) {
|
||||
t.Fatalf("unexpected response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketingPageGerman(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/product?lang=de", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, expected := range []string{"KI-Nutzung transparent kennzeichnen", "Docker Compose", "JSON-LD"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("marketing page missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketingPageLanguageNegotiation(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/product", nil)
|
||||
r.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Déclarez l’usage de l’IA") {
|
||||
t.Fatal("missing French marketing content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAlias(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/install?lang=en", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
if location := w.Header().Get("Location"); location != "/product?lang=en#install" {
|
||||
t.Fatalf("unexpected redirect %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricingRouteRemoved(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/pricing", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarationLanguageSwitcherPreservesQuery(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/declaration?mode=article&textExtent=partial&textReview=expert&coverImageExtent=partial&coverImageReview=editorial&lang=de&assurance=selfDeclared", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, expected := range []string{
|
||||
`id="declaration-language"`,
|
||||
`lang=en`,
|
||||
`textExtent=partial`,
|
||||
`textReview=expert`,
|
||||
`coverImageExtent=partial`,
|
||||
`hreflang="en"`,
|
||||
`hreflang="x-default"`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("language switcher missing %q", expected)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `<option value="/declaration?`) || !strings.Contains(body, `lang=de`) || !strings.Contains(body, ` selected`) {
|
||||
t.Fatal("current language is not selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleBadgeUsesCalmVioletDefault(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/badge.svg?mode=article&textExtent=none&textReview=none&imageExtent=full&imageReview=editorial&lang=de", nil)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler(t).ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `fill="#7c3aed"`) {
|
||||
t.Fatalf("article badge does not use violet default: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorExposesAssuranceSelection(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="assurance"`,
|
||||
`value="selfDeclared"`,
|
||||
`value="technicallyRecorded"`,
|
||||
`value="signed"`,
|
||||
`value="verified"`,
|
||||
`Nachweisgrundlage`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("generator missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarationUsesSelectedAssurance(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/declaration?preset=research&lang=de&assurance=technicallyRecorded", 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{
|
||||
`"assurance":"technicallyRecorded"`,
|
||||
`Als Nachweisgrundlage ist eine technische Protokollierung im Erstellungs- oder Veröffentlichungsprozess angegeben.`,
|
||||
`<colgroup>`,
|
||||
`scope="col"`,
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("declaration missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedAssuranceExplainsScope(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/declaration?preset=summary&lang=de&assurance=signed", 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(), "Die Signatur bestätigt nicht automatisch die inhaltliche Richtigkeit der Angaben.") {
|
||||
t.Fatal("signed assurance scope is not explained")
|
||||
}
|
||||
}
|
||||
1
internal/app/trusted_keys.json
Normal file
1
internal/app/trusted_keys.json
Normal file
@@ -0,0 +1 @@
|
||||
{"licenseKeys":{"issuer-2026":"8NpJ01Ew-wGyLbZnBsT2lrMFRKERFS-_eqREUJVHLYk"},"leaseKeys":{"lease-2026":"QhFaAPcgT9ZVxQF9jwQ-aYFptu9mpoROlUdW5t6aTeM"}}
|
||||
108
internal/badge/render.go
Normal file
108
internal/badge/render.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package badge
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Label string
|
||||
Message string
|
||||
Extent string
|
||||
Style string
|
||||
Theme string
|
||||
Link string
|
||||
LeftColor string
|
||||
RightColor string
|
||||
}
|
||||
|
||||
var extentColors = map[string]string{
|
||||
"none": "#2f855a", "assisted": "#2b6cb0", "partial": "#b7791f", "mostly": "#c05621", "full": "#c53030",
|
||||
}
|
||||
|
||||
func Render(o Options) ([]byte, string) {
|
||||
label := truncate(strings.TrimSpace(o.Label), 40)
|
||||
if label == "" {
|
||||
label = "AI use"
|
||||
}
|
||||
message := truncate(strings.TrimSpace(o.Message), 80)
|
||||
if message == "" {
|
||||
message = o.Extent
|
||||
}
|
||||
style := o.Style
|
||||
if style != "flat-square" {
|
||||
style = "flat"
|
||||
}
|
||||
|
||||
leftWidth := textWidth(label)
|
||||
rightWidth := textWidth(message)
|
||||
total := leftWidth + rightWidth
|
||||
radius := 3
|
||||
if style == "flat-square" {
|
||||
radius = 0
|
||||
}
|
||||
|
||||
leftColor, rightColor := "#555555", extentColors[o.Extent]
|
||||
if rightColor == "" {
|
||||
rightColor = "#4a5568"
|
||||
}
|
||||
if o.Theme == "mono" {
|
||||
leftColor, rightColor = "#262626", "#666666"
|
||||
}
|
||||
if isHexColor(o.LeftColor) {
|
||||
leftColor = o.LeftColor
|
||||
}
|
||||
if isHexColor(o.RightColor) {
|
||||
rightColor = o.RightColor
|
||||
}
|
||||
|
||||
title := html.EscapeString(label + ": " + message)
|
||||
labelEsc := html.EscapeString(label)
|
||||
messageEsc := html.EscapeString(message)
|
||||
|
||||
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)
|
||||
data := []byte(svg)
|
||||
hash := sha256.Sum256(data)
|
||||
return data, `"` + hex.EncodeToString(hash[:12]) + `"`
|
||||
}
|
||||
|
||||
func textWidth(s string) int {
|
||||
runes := utf8.RuneCountInString(s)
|
||||
width := runes*7 + 12
|
||||
if width < 40 {
|
||||
width = 40
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func isHexColor(s string) bool {
|
||||
if len(s) != 7 || s[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, r := range s[1:] {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
19
internal/badge/render_test.go
Normal file
19
internal/badge/render_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package badge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderEscapesText(t *testing.T) {
|
||||
data, etag := Render(Options{Label: `<script>`, Message: `A&B`, Extent: "assisted"})
|
||||
if bytes.Contains(data, []byte(`<script>`)) {
|
||||
t.Fatal("unescaped label")
|
||||
}
|
||||
if !bytes.Contains(data, []byte(`A&B`)) {
|
||||
t.Fatal("message not escaped")
|
||||
}
|
||||
if etag == "" {
|
||||
t.Fatal("missing etag")
|
||||
}
|
||||
}
|
||||
343
internal/declaration/model.go
Normal file
343
internal/declaration/model.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/ai-disclosure-standard/internal/i18n"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = "1.1"
|
||||
LegacySchemaVersion = "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")
|
||||
hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
)
|
||||
|
||||
type Component struct {
|
||||
AIExtent string `json:"aiExtent"`
|
||||
Activities []string `json:"activities,omitempty"`
|
||||
HumanReview string `json:"humanReview"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type Responsibility struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Presentation struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
BadgeLabel string `json:"badgeLabel,omitempty"`
|
||||
BadgeMessage string `json:"badgeMessage,omitempty"`
|
||||
LeftColor string `json:"leftColor,omitempty"`
|
||||
RightColor string `json:"rightColor,omitempty"`
|
||||
}
|
||||
|
||||
type Declaration struct {
|
||||
Context string `json:"@context"`
|
||||
Type string `json:"@type"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
DeclaredAt string `json:"declaredAt,omitempty"`
|
||||
Language string `json:"language"`
|
||||
Components map[string]Component `json:"components"`
|
||||
EditorialResponsibility *Responsibility `json:"editorialResponsibility,omitempty"`
|
||||
Assurance string `json:"assurance"`
|
||||
Presentation *Presentation `json:"presentation,omitempty"`
|
||||
}
|
||||
|
||||
type Preset struct {
|
||||
ID string
|
||||
Extent string
|
||||
Activities []string
|
||||
Review string
|
||||
}
|
||||
|
||||
type ParseOptions struct {
|
||||
AllowCustomText bool
|
||||
AllowCustomBadge bool
|
||||
DefaultLanguage string
|
||||
}
|
||||
|
||||
var Presets = map[string]Preset{
|
||||
"no-ai": {ID: "no-ai", Extent: "none", Review: "editorial"},
|
||||
"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}
|
||||
var validReviews = map[string]bool{"none": true, "basic": true, "editorial": true, "expert": true}
|
||||
var validAssurance = map[string]bool{"selfDeclared": true, "technicallyRecorded": true, "signed": true, "verified": true}
|
||||
var validActivities = map[string]bool{
|
||||
"research": true, "summarisation": true, "drafting": true, "generation": true,
|
||||
"translation": true, "editing": true, "imageGeneration": true, "codeGeneration": true,
|
||||
"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}
|
||||
|
||||
func NewFromQuery(values url.Values, contextURL string) (Declaration, error) {
|
||||
return NewFromQueryWithOptions(values, contextURL, ParseOptions{DefaultLanguage: "de"})
|
||||
}
|
||||
|
||||
func NewFromQueryWithOptions(values url.Values, contextURL string, options ParseOptions) (Declaration, error) {
|
||||
lang := i18n.Normalize(clean(values.Get("lang"), 16))
|
||||
if lang == "" {
|
||||
lang = i18n.Normalize(options.DefaultLanguage)
|
||||
}
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
if !i18n.Supported(lang) {
|
||||
return Declaration{}, fmt.Errorf("unsupported language %q", lang)
|
||||
}
|
||||
|
||||
componentName := clean(values.Get("component"), 32)
|
||||
if componentName == "" {
|
||||
componentName = "text"
|
||||
}
|
||||
if !validComponents[componentName] {
|
||||
return Declaration{}, fmt.Errorf("unknown component %q", componentName)
|
||||
}
|
||||
|
||||
extent := clean(values.Get("extent"), 24)
|
||||
activities := splitCSV(values.Get("activities"))
|
||||
review := clean(values.Get("review"), 24)
|
||||
|
||||
if presetID := clean(values.Get("preset"), 24); presetID != "" {
|
||||
preset, ok := Presets[presetID]
|
||||
if !ok {
|
||||
return Declaration{}, fmt.Errorf("unknown preset %q", presetID)
|
||||
}
|
||||
if extent == "" {
|
||||
extent = preset.Extent
|
||||
}
|
||||
if len(activities) == 0 {
|
||||
activities = append([]string(nil), preset.Activities...)
|
||||
}
|
||||
if review == "" {
|
||||
review = preset.Review
|
||||
}
|
||||
}
|
||||
if extent == "" {
|
||||
extent = "assisted"
|
||||
}
|
||||
if review == "" {
|
||||
review = "editorial"
|
||||
}
|
||||
assurance := clean(values.Get("assurance"), 32)
|
||||
if assurance == "" {
|
||||
assurance = "selfDeclared"
|
||||
}
|
||||
|
||||
components := map[string]Component{}
|
||||
if clean(values.Get("mode"), 16) == "article" {
|
||||
for _, name := range []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code"} {
|
||||
componentExtent := clean(values.Get(name+"Extent"), 24)
|
||||
if componentExtent == "" {
|
||||
continue
|
||||
}
|
||||
componentReview := clean(values.Get(name+"Review"), 24)
|
||||
if componentReview == "" {
|
||||
if componentExtent == "none" {
|
||||
componentReview = "none"
|
||||
} else {
|
||||
componentReview = "editorial"
|
||||
}
|
||||
}
|
||||
componentActivities := splitCSV(values.Get(name + "Activities"))
|
||||
if len(componentActivities) == 0 {
|
||||
switch name {
|
||||
case "research":
|
||||
if componentExtent != "none" {
|
||||
componentActivities = []string{"research"}
|
||||
}
|
||||
case "translation":
|
||||
if componentExtent != "none" {
|
||||
componentActivities = []string{"translation"}
|
||||
}
|
||||
case "coverImage", "image":
|
||||
if componentExtent != "none" {
|
||||
componentActivities = []string{"imageGeneration"}
|
||||
}
|
||||
case "code":
|
||||
if componentExtent != "none" {
|
||||
componentActivities = []string{"codeGeneration"}
|
||||
}
|
||||
}
|
||||
}
|
||||
components[name] = Component{AIExtent: componentExtent, Activities: componentActivities, HumanReview: componentReview, Note: clean(values.Get(name+"Note"), 500)}
|
||||
}
|
||||
}
|
||||
if len(components) == 0 {
|
||||
components[componentName] = Component{AIExtent: extent, Activities: activities, HumanReview: review, Note: clean(values.Get("note"), 500)}
|
||||
}
|
||||
d := Declaration{
|
||||
Context: contextURL, Type: "AIUsageDeclaration", SchemaVersion: SchemaVersion,
|
||||
Subject: clean(values.Get("subject"), 2048), DeclaredAt: clean(values.Get("declaredAt"), 64), Language: lang,
|
||||
Components: components,
|
||||
Assurance: assurance,
|
||||
}
|
||||
responsibleName := clean(values.Get("responsible"), 200)
|
||||
responsibleURL := clean(values.Get("responsibleUrl"), 2048)
|
||||
if responsibleName != "" || responsibleURL != "" {
|
||||
d.EditorialResponsibility = &Responsibility{Name: responsibleName, URL: responsibleURL}
|
||||
}
|
||||
|
||||
presentation := &Presentation{
|
||||
Title: clean(values.Get("customTitle"), 120), Description: clean(values.Get("customDescription"), 500),
|
||||
BadgeLabel: clean(values.Get("badgeLabel"), 40), BadgeMessage: clean(values.Get("badgeMessage"), 80),
|
||||
LeftColor: clean(values.Get("leftColor"), 7), RightColor: clean(values.Get("rightColor"), 7),
|
||||
}
|
||||
if presentation.Title != "" || presentation.Description != "" {
|
||||
if !options.AllowCustomText {
|
||||
return Declaration{}, ErrCustomTextRequiresPro
|
||||
}
|
||||
}
|
||||
if presentation.BadgeLabel != "" || presentation.BadgeMessage != "" || presentation.LeftColor != "" || presentation.RightColor != "" {
|
||||
if !options.AllowCustomBadge {
|
||||
return Declaration{}, ErrCustomBadgeRequiresPro
|
||||
}
|
||||
}
|
||||
if !presentation.empty() {
|
||||
d.Presentation = presentation
|
||||
}
|
||||
return d, Validate(d)
|
||||
}
|
||||
|
||||
func Validate(d Declaration) error {
|
||||
var problems []string
|
||||
switch d.SchemaVersion {
|
||||
case SchemaVersion:
|
||||
case LegacySchemaVersion:
|
||||
if d.Presentation != nil {
|
||||
problems = append(problems, "presentation requires schemaVersion 1.1")
|
||||
}
|
||||
if d.Language != "de" && d.Language != "en" {
|
||||
problems = append(problems, "schemaVersion 1.0 supports only de and en")
|
||||
}
|
||||
default:
|
||||
problems = append(problems, "unsupported schemaVersion")
|
||||
}
|
||||
if d.Type != "AIUsageDeclaration" {
|
||||
problems = append(problems, "@type must be AIUsageDeclaration")
|
||||
}
|
||||
if !i18n.Supported(d.Language) {
|
||||
problems = append(problems, "unsupported language")
|
||||
}
|
||||
if !validAssurance[d.Assurance] {
|
||||
problems = append(problems, "invalid assurance")
|
||||
}
|
||||
if len(d.Components) == 0 {
|
||||
problems = append(problems, "at least one component is required")
|
||||
}
|
||||
if d.Subject != "" {
|
||||
if u, err := url.ParseRequestURI(d.Subject); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
problems = append(problems, "subject must be an absolute URL")
|
||||
}
|
||||
}
|
||||
if d.DeclaredAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339, d.DeclaredAt); err != nil {
|
||||
problems = append(problems, "declaredAt must be RFC3339")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
for name, c := range d.Components {
|
||||
if !validComponents[name] {
|
||||
problems = append(problems, "invalid component: "+name)
|
||||
}
|
||||
if !validExtents[c.AIExtent] {
|
||||
problems = append(problems, "invalid aiExtent for "+name)
|
||||
}
|
||||
if !validReviews[c.HumanReview] {
|
||||
problems = append(problems, "invalid humanReview for "+name)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, activity := range c.Activities {
|
||||
if !validActivities[activity] {
|
||||
problems = append(problems, "invalid activity for "+name+": "+activity)
|
||||
}
|
||||
if seen[activity] {
|
||||
problems = append(problems, "duplicate activity for "+name+": "+activity)
|
||||
}
|
||||
seen[activity] = true
|
||||
}
|
||||
if c.AIExtent == "none" && len(c.Activities) > 0 {
|
||||
problems = append(problems, "activities must be empty when aiExtent is none")
|
||||
}
|
||||
}
|
||||
if p := d.Presentation; p != nil {
|
||||
if runeLen(p.Title) > 120 {
|
||||
problems = append(problems, "presentation.title is too long")
|
||||
}
|
||||
if runeLen(p.Description) > 500 {
|
||||
problems = append(problems, "presentation.description is too long")
|
||||
}
|
||||
if runeLen(p.BadgeLabel) > 40 {
|
||||
problems = append(problems, "presentation.badgeLabel is too long")
|
||||
}
|
||||
if runeLen(p.BadgeMessage) > 80 {
|
||||
problems = append(problems, "presentation.badgeMessage is too long")
|
||||
}
|
||||
if p.LeftColor != "" && !hexColorPattern.MatchString(p.LeftColor) {
|
||||
problems = append(problems, "presentation.leftColor must be a six-digit hex colour")
|
||||
}
|
||||
if p.RightColor != "" && !hexColorPattern.MatchString(p.RightColor) {
|
||||
problems = append(problems, "presentation.rightColor must be a six-digit hex colour")
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return fmt.Errorf("%s", strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsValidExtent(value string) bool { return validExtents[value] }
|
||||
|
||||
func (p *Presentation) empty() bool {
|
||||
return p == nil || (p.Title == "" && p.Description == "" && p.BadgeLabel == "" && p.BadgeMessage == "" && p.LeftColor == "" && p.RightColor == "")
|
||||
}
|
||||
|
||||
func clean(s string, max int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) > max {
|
||||
r = r[:max]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
func runeLen(s string) int { return len([]rune(s)) }
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, p := range parts {
|
||||
p = clean(p, 32)
|
||||
if p != "" && !seen[p] {
|
||||
out = append(out, p)
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
114
internal/declaration/model_test.go
Normal file
114
internal/declaration/model_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPresetQuery(t *testing.T) {
|
||||
v := url.Values{"preset": {"research"}, "subject": {"https://example.org/post"}, "lang": {"fr"}}
|
||||
d, err := NewFromQuery(v, "https://example.org/context/v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := d.Components["text"]
|
||||
if c.AIExtent != "assisted" || len(c.Activities) != 1 || c.Activities[0] != "research" {
|
||||
t.Fatalf("unexpected component: %#v", c)
|
||||
}
|
||||
if d.Language != "fr" {
|
||||
t.Fatalf("unexpected language: %s", d.Language)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoAIRejectsActivities(t *testing.T) {
|
||||
d := Declaration{Context: "https://example.org/context/v1", Type: "AIUsageDeclaration", SchemaVersion: SchemaVersion, Language: "de", Assurance: "selfDeclared", Components: map[string]Component{"text": {AIExtent: "none", Activities: []string{"research"}, HumanReview: "editorial"}}}
|
||||
if Validate(d) == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRequiresPro(t *testing.T) {
|
||||
v := url.Values{"preset": {"research"}, "lang": {"en"}, "customTitle": {"Custom"}}
|
||||
_, err := NewFromQuery(v, "https://example.org/context/v1")
|
||||
if !errors.Is(err, ErrCustomTextRequiresPro) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
d, err := NewFromQueryWithOptions(v, "https://example.org/context/v1", ParseOptions{AllowCustomText: true, DefaultLanguage: "en"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Presentation == nil || d.Presentation.Title != "Custom" {
|
||||
t.Fatalf("missing presentation: %#v", d.Presentation)
|
||||
}
|
||||
}
|
||||
|
||||
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"}}}
|
||||
if err := Validate(d); err != nil {
|
||||
t.Fatalf("legacy declaration should remain valid: %v", err)
|
||||
}
|
||||
d.Presentation = &Presentation{Title: "Not allowed in 1.0"}
|
||||
if Validate(d) == nil {
|
||||
t.Fatal("expected presentation to require schema 1.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleDeclarationFromQuery(t *testing.T) {
|
||||
q := url.Values{
|
||||
"mode": {"article"}, "lang": {"de"},
|
||||
"textExtent": {"none"}, "imageExtent": {"full"}, "researchExtent": {"assisted"},
|
||||
}
|
||||
d, err := NewFromQuery(q, "https://example.org/context/v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.Components) != 3 {
|
||||
t.Fatalf("components=%d", len(d.Components))
|
||||
}
|
||||
if d.Components["text"].AIExtent != "none" {
|
||||
t.Fatalf("unexpected text extent")
|
||||
}
|
||||
if d.Components["text"].HumanReview != "none" {
|
||||
t.Fatalf("no-AI component review=%q, want none", d.Components["text"].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)
|
||||
}
|
||||
if got := d.Components["image"].Activities; len(got) != 1 || got[0] != "imageGeneration" {
|
||||
t.Fatalf("image activities=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleDeclarationKeepsPerComponentReviews(t *testing.T) {
|
||||
q := url.Values{
|
||||
"mode": {"article"}, "lang": {"de"},
|
||||
"textExtent": {"assisted"}, "textReview": {"basic"},
|
||||
"imageExtent": {"full"}, "imageReview": {"expert"},
|
||||
}
|
||||
d, err := NewFromQuery(q, "https://example.org/context/v1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := d.Components["text"].HumanReview; got != "basic" {
|
||||
t.Fatalf("text review=%q", got)
|
||||
}
|
||||
if got := d.Components["image"].HumanReview; got != "expert" {
|
||||
t.Fatalf("image review=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssuranceFromQuery(t *testing.T) {
|
||||
for _, assurance := range []string{"selfDeclared", "technicallyRecorded", "signed", "verified"} {
|
||||
d, err := NewFromQuery(url.Values{"preset": {"research"}, "lang": {"de"}, "assurance": {assurance}}, "https://example.org/context/v1")
|
||||
if err != nil {
|
||||
t.Fatalf("assurance %s: %v", assurance, err)
|
||||
}
|
||||
if d.Assurance != assurance {
|
||||
t.Fatalf("assurance=%q, want %q", d.Assurance, assurance)
|
||||
}
|
||||
}
|
||||
}
|
||||
430
internal/i18n/catalog.go
Normal file
430
internal/i18n/catalog.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PresetText struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Locale struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Text map[string]string `json:"text"`
|
||||
Presets map[string]PresetText `json:"presets"`
|
||||
Extents map[string]string `json:"extents"`
|
||||
Components map[string]string `json:"components"`
|
||||
Reviews map[string]string `json:"reviews"`
|
||||
Activities map[string]string `json:"activities"`
|
||||
Assurances map[string]string `json:"assurances"`
|
||||
}
|
||||
|
||||
type LanguageOption struct {
|
||||
Code string
|
||||
Name string
|
||||
}
|
||||
|
||||
var catalogs = map[string]Locale{
|
||||
"de": locale("de", "Deutsch", map[string]string{
|
||||
"meta_description": "Offene, maschinenlesbare und selbst hostbare Kennzeichnung von KI-Nutzung.",
|
||||
"nav_generator": "Generator", "nav_api": "API", "nav_background": "Hintergrund",
|
||||
"standard_eyebrow": "Offener Kennzeichnungsstandard", "hero_title": "Offenlegen, wie KI an einem Inhalt beteiligt war.",
|
||||
"hero_lead": "Sichtbares SVG-Badge, verständliche Erklärungsseite und maschinenlesbares JSON-LD – ohne Cookies, Tracking oder Datenbankzwang.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
"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_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",
|
||||
"ai_label": "KI-Nutzung", "capabilities": "Funktionen",
|
||||
},
|
||||
map[string]PresetText{
|
||||
"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", "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{
|
||||
"meta_description": "Open, machine-readable and self-hostable AI usage disclosure.", "nav_generator": "Generator", "nav_api": "API", "nav_background": "Background",
|
||||
"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.", "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": "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.",
|
||||
"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_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", "ai_label": "AI use", "capabilities": "Capabilities",
|
||||
},
|
||||
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", "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",
|
||||
map[string]PresetText{"no-ai": {"Aucune utilisation de l’IA", "Le contenu identifié a été créé sans IA générative et vérifié par une personne."}, "research": {"Aide à la recherche", "L’IA a été utilisée pour la recherche ou l’identification de sources. Le contenu a été rédigé et vérifié éditorialement."}, "summary": {"Résumé de contenu", "L’IA a été utilisée pour résumer des sources. La sélection, l’interprétation et la version finale ont été vérifiées par une personne."}, "full": {"Génération complète du contenu", "Le contenu a été majoritairement ou entièrement généré par l’IA puis vérifié éditorialement."}}),
|
||||
"es": europeanLocale("es", "Español", "Indica cómo ha contribuido la IA a un contenido.", "Uso de IA", "Sin uso de IA", "Asistido por IA", "Generado parcialmente por IA", "Generado mayoritariamente por IA", "Generado completamente por IA",
|
||||
map[string]PresetText{"no-ai": {"Sin uso de IA", "El contenido identificado se creó sin IA generativa y fue revisado por una persona."}, "research": {"Apoyo a la investigación", "La IA se utilizó para investigar o localizar fuentes relevantes. El contenido se redactó y revisó editorialmente."}, "summary": {"Resumen de contenido", "La IA se utilizó para resumir fuentes. La selección, interpretación y versión final fueron revisadas por una persona."}, "full": {"Generación completa del contenido", "El contenido fue generado mayoritaria o completamente por IA y posteriormente revisado editorialmente."}}),
|
||||
"it": europeanLocale("it", "Italiano", "Dichiara come l’IA ha contribuito a un contenuto.", "Uso dell’IA", "Nessun uso dell’IA", "Assistito dall’IA", "Generato parzialmente dall’IA", "Generato prevalentemente dall’IA", "Generato interamente dall’IA",
|
||||
map[string]PresetText{"no-ai": {"Nessun uso dell’IA", "Il contenuto indicato è stato creato senza IA generativa e verificato da una persona."}, "research": {"Supporto alla ricerca", "L’IA è stata usata per la ricerca o per individuare fonti rilevanti. Il contenuto è stato creato e verificato editorialmente."}, "summary": {"Sintesi del contenuto", "L’IA è stata usata per riassumere le fonti. Selezione, interpretazione e versione finale sono state verificate da una persona."}, "full": {"Generazione completa del contenuto", "Il contenuto è stato generato prevalentemente o interamente dall’IA e poi verificato editorialmente."}}),
|
||||
"nl": europeanLocale("nl", "Nederlands", "Maak zichtbaar hoe AI aan inhoud heeft bijgedragen.", "AI-gebruik", "Geen AI gebruikt", "AI-ondersteund", "Gedeeltelijk door AI gegenereerd", "Grotendeels door AI gegenereerd", "Volledig door AI gegenereerd",
|
||||
map[string]PresetText{"no-ai": {"Geen AI gebruikt", "De gemarkeerde inhoud is zonder generatieve AI gemaakt en door een mens gecontroleerd."}, "research": {"Onderzoeksondersteuning", "AI is gebruikt voor onderzoek of het vinden van relevante bronnen. De inhoud is redactioneel gemaakt en gecontroleerd."}, "summary": {"Samenvatting van inhoud", "AI is gebruikt om bronnen samen te vatten. Selectie, duiding en eindversie zijn door een mens gecontroleerd."}, "full": {"Volledige inhoudsgeneratie", "De inhoud is grotendeels of volledig door AI gegenereerd en daarna redactioneel gecontroleerd."}}),
|
||||
"pt": europeanLocale("pt", "Português", "Declare como a IA contribuiu para um conteúdo.", "Uso de IA", "Sem uso de IA", "Assistido por IA", "Parcialmente gerado por IA", "Maioritariamente gerado por IA", "Totalmente gerado por IA",
|
||||
map[string]PresetText{"no-ai": {"Sem uso de IA", "O conteúdo identificado foi criado sem IA generativa e revisto por uma pessoa."}, "research": {"Apoio à pesquisa", "A IA foi usada para pesquisa ou identificação de fontes relevantes. O conteúdo foi criado e revisto editorialmente."}, "summary": {"Resumo de conteúdo", "A IA foi usada para resumir fontes. A seleção, interpretação e versão final foram revistas por uma pessoa."}, "full": {"Geração completa do conteúdo", "O conteúdo foi maioritariamente ou totalmente gerado por IA e depois revisto editorialmente."}}),
|
||||
"pl": europeanLocale("pl", "Polski", "Pokaż, w jaki sposób AI uczestniczyła w tworzeniu treści.", "Użycie AI", "Bez użycia AI", "Wspomagane przez AI", "Częściowo wygenerowane przez AI", "W większości wygenerowane przez AI", "W pełni wygenerowane przez AI",
|
||||
map[string]PresetText{"no-ai": {"Bez użycia AI", "Oznaczona treść została utworzona bez generatywnej AI i sprawdzona przez człowieka."}, "research": {"Wsparcie badań", "AI wykorzystano do wyszukiwania informacji lub odpowiednich źródeł. Treść została opracowana i sprawdzona redakcyjnie."}, "summary": {"Streszczenie treści", "AI wykorzystano do streszczania źródeł. Wybór, interpretacja i wersja końcowa zostały sprawdzone przez człowieka."}, "full": {"Pełne generowanie treści", "Treść została w większości lub w całości wygenerowana przez AI, a następnie sprawdzona redakcyjnie."}}),
|
||||
}
|
||||
|
||||
func locale(code, name string, text map[string]string, presets map[string]PresetText, extents, components, reviews, activities, assurances map[string]string) Locale {
|
||||
return Locale{Code: code, Name: name, Text: text, Presets: presets, Extents: extents, Components: components, Reviews: reviews, Activities: activities, Assurances: assurances}
|
||||
}
|
||||
|
||||
// europeanLocale starts with the locale-specific public wording. init fills missing
|
||||
// administrative UI labels from English so every supported locale is complete.
|
||||
func europeanLocale(code, name, hero, aiLabel, none, assisted, partial, mostly, full string, presets map[string]PresetText) Locale {
|
||||
return Locale{
|
||||
Code: code, Name: name,
|
||||
Text: map[string]string{"hero_title": hero, "ai_label": aiLabel},
|
||||
Presets: presets,
|
||||
Extents: map[string]string{"none": none, "assisted": assisted, "partial": partial, "mostly": mostly, "full": full},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
base := catalogs["en"]
|
||||
for _, code := range []string{"fr", "es", "it", "nl", "pt", "pl"} {
|
||||
l := catalogs[code]
|
||||
for k, v := range base.Text {
|
||||
if l.Text[k] == "" {
|
||||
l.Text[k] = v
|
||||
}
|
||||
}
|
||||
l.Components = cloneMap(base.Components)
|
||||
l.Reviews = cloneMap(base.Reviews)
|
||||
l.Activities = cloneMap(base.Activities)
|
||||
l.Assurances = cloneMap(base.Assurances)
|
||||
catalogs[code] = l
|
||||
}
|
||||
for code, overrides := range europeanTextOverrides() {
|
||||
l := catalogs[code]
|
||||
for k, v := range overrides {
|
||||
l.Text[k] = v
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
applyEuropeanTaxonomyTranslations()
|
||||
applyArticleTranslations()
|
||||
applyAssuranceTranslations()
|
||||
}
|
||||
|
||||
func applyArticleTranslations() {
|
||||
texts := map[string]map[string]string{
|
||||
"de": {"field_mode": "Art der Erklärung", "mode_single": "Einzelne Nutzung", "mode_article": "Artikel oder Webseite zusammenfassen", "article_fields": "Bestandteile des Artikels", "article_help": "Lege für jeden typischen Bestandteil fest, ob und wie KI eingesetzt wurde.", "article_title": "Dokumentation der KI-Nutzung", "article_summary_heading": "Zusammenfassende Einordnung", "article_table_heading": "Strukturierte Angaben nach Inhaltsbestandteil", "table_component": "Bereich", "table_extent": "KI-Nutzung", "table_activities": "Einsatzgebiet", "table_review": "Menschliche Prüfung", "badge_article": "Artikeltransparenz"},
|
||||
"en": {"field_mode": "Declaration type", "mode_single": "Single use", "mode_article": "Summarise an article or website", "article_fields": "Article components", "article_help": "Specify whether and how AI was used for each typical component.", "article_title": "Documentation of AI use", "article_summary_heading": "Summary assessment", "article_table_heading": "Structured details by content component", "table_component": "Area", "table_extent": "AI use", "table_activities": "Purpose", "table_review": "Human review", "badge_article": "Article transparency"},
|
||||
"fr": {"field_mode": "Type de déclaration", "mode_single": "Utilisation individuelle", "mode_article": "Résumer un article ou un site", "article_fields": "Composants de l’article", "article_help": "Indiquez si et comment l’IA a été utilisée pour chaque composant.", "article_title": "Utilisation de l’IA en un coup d’œil", "article_summary_heading": "Résumé", "article_table_heading": "Détails par composant", "table_component": "Élément", "table_extent": "Utilisation de l’IA", "table_activities": "Usage", "table_review": "Vérification humaine", "badge_article": "Transparence de l’article"},
|
||||
"es": {"field_mode": "Tipo de declaración", "mode_single": "Uso individual", "mode_article": "Resumir un artículo o sitio", "article_fields": "Componentes del artículo", "article_help": "Indica si se utilizó IA y de qué manera en cada componente.", "article_title": "Uso de IA de un vistazo", "article_summary_heading": "Resumen", "article_table_heading": "Detalles por componente", "table_component": "Área", "table_extent": "Uso de IA", "table_activities": "Finalidad", "table_review": "Revisión humana", "badge_article": "Transparencia del artículo"},
|
||||
"it": {"field_mode": "Tipo di dichiarazione", "mode_single": "Uso singolo", "mode_article": "Riassumi un articolo o sito", "article_fields": "Componenti dell’articolo", "article_help": "Indica se e come l’IA è stata usata per ogni componente.", "article_title": "Uso dell’IA in sintesi", "article_summary_heading": "Riepilogo", "article_table_heading": "Dettagli per componente", "table_component": "Area", "table_extent": "Uso dell’IA", "table_activities": "Finalità", "table_review": "Revisione umana", "badge_article": "Trasparenza dell’articolo"},
|
||||
"nl": {"field_mode": "Type verklaring", "mode_single": "Afzonderlijk gebruik", "mode_article": "Artikel of website samenvatten", "article_fields": "Onderdelen van het artikel", "article_help": "Geef per onderdeel aan of en hoe AI is gebruikt.", "article_title": "AI-gebruik in één oogopslag", "article_summary_heading": "Samenvatting", "article_table_heading": "Details per onderdeel", "table_component": "Onderdeel", "table_extent": "AI-gebruik", "table_activities": "Doel", "table_review": "Menselijke controle", "badge_article": "Artikeltransparantie"},
|
||||
"pt": {"field_mode": "Tipo de declaração", "mode_single": "Uso individual", "mode_article": "Resumir um artigo ou site", "article_fields": "Componentes do artigo", "article_help": "Indique se e como a IA foi usada em cada componente.", "article_title": "Utilização de IA em resumo", "article_summary_heading": "Resumo", "article_table_heading": "Detalhes por componente", "table_component": "Área", "table_extent": "Utilização de IA", "table_activities": "Finalidade", "table_review": "Revisão humana", "badge_article": "Transparência do artigo"},
|
||||
"pl": {"field_mode": "Typ deklaracji", "mode_single": "Pojedyncze użycie", "mode_article": "Podsumuj artykuł lub stronę", "article_fields": "Elementy artykułu", "article_help": "Określ, czy i jak użyto AI w każdym elemencie.", "article_title": "Użycie AI w skrócie", "article_summary_heading": "Podsumowanie", "article_table_heading": "Szczegóły według elementu", "table_component": "Obszar", "table_extent": "Użycie AI", "table_activities": "Cel", "table_review": "Weryfikacja człowieka", "badge_article": "Przejrzystość artykułu"},
|
||||
}
|
||||
componentNames := map[string]map[string]string{
|
||||
"de": {"research": "Recherche", "translation": "Übersetzung"}, "en": {"research": "Research", "translation": "Translation"},
|
||||
"fr": {"research": "Recherche", "translation": "Traduction"}, "es": {"research": "Investigación", "translation": "Traducción"},
|
||||
"it": {"research": "Ricerca", "translation": "Traduzione"}, "nl": {"research": "Onderzoek", "translation": "Vertaling"},
|
||||
"pt": {"research": "Pesquisa", "translation": "Tradução"}, "pl": {"research": "Badania", "translation": "Tłumaczenie"},
|
||||
}
|
||||
for code, values := range texts {
|
||||
l := catalogs[code]
|
||||
for k, v := range values {
|
||||
l.Text[k] = v
|
||||
}
|
||||
for k, v := range componentNames[code] {
|
||||
l.Components[k] = v
|
||||
}
|
||||
catalogs[code] = l
|
||||
}
|
||||
}
|
||||
|
||||
func europeanTextOverrides() map[string]map[string]string {
|
||||
return map[string]map[string]string{
|
||||
"fr": {
|
||||
"meta_description": "Déclaration ouverte, lisible par machine et auto-hébergeable de l’utilisation de l’IA.", "nav_generator": "Générateur", "nav_background": "Contexte",
|
||||
"standard_eyebrow": "Norme ouverte de transparence", "hero_lead": "Badge SVG visible, page explicative et JSON-LD lisible par machine – sans cookies, suivi ni base de données obligatoire.",
|
||||
"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",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func applyEuropeanTaxonomyTranslations() {
|
||||
translations := map[string]struct {
|
||||
components, reviews, activities, assurances map[string]string
|
||||
}{
|
||||
"fr": {
|
||||
components: map[string]string{"text": "Texte", "coverImage": "Image de couverture", "image": "Image", "audio": "Audio", "video": "Vidéo", "code": "Code", "other": "Autre"},
|
||||
reviews: map[string]string{"none": "Aucune", "basic": "Basique", "editorial": "Éditoriale", "expert": "Experte"},
|
||||
activities: map[string]string{"research": "Recherche", "summarisation": "Résumé", "drafting": "Rédaction initiale", "generation": "Génération", "translation": "Traduction", "editing": "Révision", "imageGeneration": "Génération d’image", "codeGeneration": "Génération de code", "transcription": "Transcription", "classification": "Classification"},
|
||||
assurances: map[string]string{"selfDeclared": "Auto-déclaré", "technicallyRecorded": "Enregistré techniquement", "signed": "Signé", "verified": "Vérifié"},
|
||||
},
|
||||
"es": {components: map[string]string{"text": "Texto", "coverImage": "Imagen de portada", "image": "Imagen", "audio": "Audio", "video": "Vídeo", "code": "Código", "other": "Otro"}, reviews: map[string]string{"none": "Ninguna", "basic": "Básica", "editorial": "Editorial", "expert": "Experta"}, activities: map[string]string{"research": "Investigación", "summarisation": "Resumen", "drafting": "Borrador", "generation": "Generación", "translation": "Traducción", "editing": "Edición", "imageGeneration": "Generación de imágenes", "codeGeneration": "Generación de código", "transcription": "Transcripción", "classification": "Clasificación"}, assurances: map[string]string{"selfDeclared": "Autodeclarado", "technicallyRecorded": "Registrado técnicamente", "signed": "Firmado", "verified": "Verificado"}},
|
||||
"it": {components: map[string]string{"text": "Testo", "coverImage": "Immagine di copertina", "image": "Immagine", "audio": "Audio", "video": "Video", "code": "Codice", "other": "Altro"}, reviews: map[string]string{"none": "Nessuna", "basic": "Di base", "editorial": "Editoriale", "expert": "Esperta"}, activities: map[string]string{"research": "Ricerca", "summarisation": "Sintesi", "drafting": "Bozza", "generation": "Generazione", "translation": "Traduzione", "editing": "Revisione", "imageGeneration": "Generazione di immagini", "codeGeneration": "Generazione di codice", "transcription": "Trascrizione", "classification": "Classificazione"}, assurances: map[string]string{"selfDeclared": "Autodichiarato", "technicallyRecorded": "Registrato tecnicamente", "signed": "Firmato", "verified": "Verificato"}},
|
||||
"nl": {components: map[string]string{"text": "Tekst", "coverImage": "Omslagafbeelding", "image": "Afbeelding", "audio": "Audio", "video": "Video", "code": "Code", "other": "Overig"}, reviews: map[string]string{"none": "Geen", "basic": "Basis", "editorial": "Redactioneel", "expert": "Deskundig"}, activities: map[string]string{"research": "Onderzoek", "summarisation": "Samenvatting", "drafting": "Concept", "generation": "Generatie", "translation": "Vertaling", "editing": "Bewerking", "imageGeneration": "Afbeeldingsgeneratie", "codeGeneration": "Codegeneratie", "transcription": "Transcriptie", "classification": "Classificatie"}, assurances: map[string]string{"selfDeclared": "Zelf verklaard", "technicallyRecorded": "Technisch vastgelegd", "signed": "Ondertekend", "verified": "Geverifieerd"}},
|
||||
"pt": {components: map[string]string{"text": "Texto", "coverImage": "Imagem de capa", "image": "Imagem", "audio": "Áudio", "video": "Vídeo", "code": "Código", "other": "Outro"}, reviews: map[string]string{"none": "Nenhuma", "basic": "Básica", "editorial": "Editorial", "expert": "Especializada"}, activities: map[string]string{"research": "Pesquisa", "summarisation": "Resumo", "drafting": "Rascunho", "generation": "Geração", "translation": "Tradução", "editing": "Edição", "imageGeneration": "Geração de imagens", "codeGeneration": "Geração de código", "transcription": "Transcrição", "classification": "Classificação"}, assurances: map[string]string{"selfDeclared": "Autodeclarado", "technicallyRecorded": "Registado tecnicamente", "signed": "Assinado", "verified": "Verificado"}},
|
||||
"pl": {components: map[string]string{"text": "Tekst", "coverImage": "Obraz okładkowy", "image": "Obraz", "audio": "Audio", "video": "Wideo", "code": "Kod", "other": "Inne"}, reviews: map[string]string{"none": "Brak", "basic": "Podstawowa", "editorial": "Redakcyjna", "expert": "Ekspercka"}, activities: map[string]string{"research": "Badania", "summarisation": "Streszczenie", "drafting": "Szkic", "generation": "Generowanie", "translation": "Tłumaczenie", "editing": "Edycja", "imageGeneration": "Generowanie obrazów", "codeGeneration": "Generowanie kodu", "transcription": "Transkrypcja", "classification": "Klasyfikacja"}, assurances: map[string]string{"selfDeclared": "Samodeklaracja", "technicallyRecorded": "Zapisane technicznie", "signed": "Podpisane", "verified": "Zweryfikowane"}},
|
||||
}
|
||||
for code, tr := range translations {
|
||||
l := catalogs[code]
|
||||
l.Components, l.Reviews, l.Activities, l.Assurances = tr.components, tr.reviews, tr.activities, tr.assurances
|
||||
catalogs[code] = l
|
||||
}
|
||||
}
|
||||
|
||||
func applyAssuranceTranslations() {
|
||||
texts := map[string]map[string]string{
|
||||
"de": {
|
||||
"field_assurance": "Nachweisgrundlage",
|
||||
"fact_assurance": "Nachweisgrundlage",
|
||||
"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.",
|
||||
"not_applicable": "Nicht anwendbar",
|
||||
"not_specified": "Nicht angegeben",
|
||||
"assurance_selfDeclared_description": "Die Angaben beruhen auf einer Selbsterklärung der veröffentlichenden Person oder Organisation.",
|
||||
"assurance_technicallyRecorded_description": "Nur auswählen, wenn die Angaben im Erstellungs- oder Veröffentlichungsprozess technisch protokolliert werden.",
|
||||
"assurance_signed_description": "Nur auswählen, wenn die Erklärung tatsächlich digital signiert wird und Herkunft sowie Unverändertheit prüfbar sind.",
|
||||
"assurance_verified_description": "Nur auswählen, wenn die Angaben nach einem dokumentierten Prüfverfahren zusätzlich verifiziert wurden.",
|
||||
"assurance_selfDeclared_statement": "Die Angaben zur KI-Nutzung beruhen auf einer Selbsterklärung der veröffentlichenden Person oder Organisation.",
|
||||
"assurance_technicallyRecorded_statement": "Als Nachweisgrundlage ist eine technische Protokollierung im Erstellungs- oder Veröffentlichungsprozess angegeben.",
|
||||
"assurance_signed_statement": "Als Nachweisgrundlage ist eine digitale Signatur der Erklärung angegeben; damit können Herkunft und Unverändertheit geprüft werden.",
|
||||
"assurance_verified_statement": "Als Nachweisgrundlage ist eine zusätzliche Verifikation nach einem dokumentierten Prüfverfahren angegeben.",
|
||||
},
|
||||
"en": {
|
||||
"field_assurance": "Evidence basis",
|
||||
"fact_assurance": "Evidence basis",
|
||||
"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.",
|
||||
"not_applicable": "Not applicable",
|
||||
"not_specified": "Not specified",
|
||||
"assurance_selfDeclared_description": "The information is based on a declaration made by the publishing person or organisation.",
|
||||
"assurance_technicallyRecorded_description": "Select only when the information is technically recorded during the creation or publication workflow.",
|
||||
"assurance_signed_description": "Select only when the declaration is actually digitally signed and its origin and integrity can be checked.",
|
||||
"assurance_verified_description": "Select only when the information has been additionally verified under a documented review procedure.",
|
||||
"assurance_selfDeclared_statement": "The information on AI use is based on a declaration made by the publishing person or organisation.",
|
||||
"assurance_technicallyRecorded_statement": "Technical recording during the creation or publication workflow is stated as the evidence basis.",
|
||||
"assurance_signed_statement": "A digital signature of the declaration is stated as the evidence basis, allowing its origin and integrity to be checked.",
|
||||
"assurance_verified_statement": "Additional verification under a documented review procedure is stated as the evidence basis.",
|
||||
},
|
||||
"fr": {
|
||||
"field_assurance": "Base de preuve",
|
||||
"not_applicable": "Non applicable",
|
||||
"not_specified": "Non indiqué",
|
||||
"fact_assurance": "Base de preuve",
|
||||
"assurance_selfDeclared_description": "Les informations sont fournies par la personne ou l’organisation éditrice elle-même.",
|
||||
"assurance_technicallyRecorded_description": "Les informations ont été enregistrées techniquement pendant le processus de création ou de publication.",
|
||||
"assurance_signed_description": "La déclaration a été signée numériquement afin de permettre le contrôle de son origine et de son intégrité.",
|
||||
"assurance_verified_description": "Les informations ont en outre été vérifiées selon une procédure documentée.",
|
||||
"assurance_selfDeclared_statement": "Les informations sur l’utilisation de l’IA reposent sur une déclaration de la personne ou de l’organisation éditrice.",
|
||||
"assurance_technicallyRecorded_statement": "Un enregistrement technique pendant le processus de création ou de publication est indiqué comme base de preuve.",
|
||||
"assurance_signed_statement": "Une signature numérique de la déclaration est indiquée comme base de preuve, ce qui permet d’en contrôler l’origine et l’intégrité.",
|
||||
"assurance_verified_statement": "Une vérification supplémentaire selon une procédure documentée est indiquée comme base de preuve.",
|
||||
},
|
||||
"es": {
|
||||
"field_assurance": "Base de evidencia",
|
||||
"not_applicable": "No aplicable",
|
||||
"not_specified": "No especificado",
|
||||
"fact_assurance": "Base de evidencia",
|
||||
"assurance_selfDeclared_description": "La información es proporcionada por la propia persona u organización que publica el contenido.",
|
||||
"assurance_technicallyRecorded_description": "La información se registró técnicamente durante el proceso de creación o publicación.",
|
||||
"assurance_signed_description": "La declaración se firmó digitalmente para permitir la comprobación de su origen e integridad.",
|
||||
"assurance_verified_description": "La información fue verificada adicionalmente mediante un procedimiento documentado.",
|
||||
"assurance_selfDeclared_statement": "La información sobre el uso de IA se basa en una declaración de la persona u organización editora.",
|
||||
"assurance_technicallyRecorded_statement": "Se indica como base de evidencia un registro técnico durante el proceso de creación o publicación.",
|
||||
"assurance_signed_statement": "Se indica como base de evidencia una firma digital de la declaración, que permite comprobar su origen e integridad.",
|
||||
"assurance_verified_statement": "Se indica como base de evidencia una verificación adicional conforme a un procedimiento documentado.",
|
||||
},
|
||||
"it": {
|
||||
"field_assurance": "Base probatoria",
|
||||
"not_applicable": "Non applicabile",
|
||||
"not_specified": "Non specificato",
|
||||
"fact_assurance": "Base probatoria",
|
||||
"assurance_selfDeclared_description": "Le informazioni sono fornite dalla persona o dall’organizzazione che pubblica il contenuto.",
|
||||
"assurance_technicallyRecorded_description": "Le informazioni sono state registrate tecnicamente durante il processo di creazione o pubblicazione.",
|
||||
"assurance_signed_description": "La dichiarazione è stata firmata digitalmente per consentire la verifica dell’origine e dell’integrità.",
|
||||
"assurance_verified_description": "Le informazioni sono state inoltre verificate secondo una procedura documentata.",
|
||||
"assurance_selfDeclared_statement": "Le informazioni sull’uso dell’IA si basano su una dichiarazione della persona o dell’organizzazione che pubblica il contenuto.",
|
||||
"assurance_technicallyRecorded_statement": "Come base probatoria è indicata una registrazione tecnica durante il processo di creazione o pubblicazione.",
|
||||
"assurance_signed_statement": "Come base probatoria è indicata una firma digitale della dichiarazione, che consente di verificarne origine e integrità.",
|
||||
"assurance_verified_statement": "Come base probatoria è indicata una verifica aggiuntiva secondo una procedura documentata.",
|
||||
},
|
||||
"nl": {
|
||||
"field_assurance": "Bewijsgrondslag",
|
||||
"not_applicable": "Niet van toepassing",
|
||||
"not_specified": "Niet vermeld",
|
||||
"fact_assurance": "Bewijsgrondslag",
|
||||
"assurance_selfDeclared_description": "De informatie is verstrekt door de publicerende persoon of organisatie zelf.",
|
||||
"assurance_technicallyRecorded_description": "De informatie is technisch vastgelegd tijdens het creatie- of publicatieproces.",
|
||||
"assurance_signed_description": "De verklaring is digitaal ondertekend, zodat herkomst en integriteit kunnen worden gecontroleerd.",
|
||||
"assurance_verified_description": "De informatie is aanvullend geverifieerd volgens een gedocumenteerde procedure.",
|
||||
"assurance_selfDeclared_statement": "De informatie over AI-gebruik is gebaseerd op een verklaring van de publicerende persoon of organisatie.",
|
||||
"assurance_technicallyRecorded_statement": "Technische vastlegging tijdens het creatie- of publicatieproces is als bewijsgrondslag aangegeven.",
|
||||
"assurance_signed_statement": "Een digitale ondertekening van de verklaring is als bewijsgrondslag aangegeven, zodat herkomst en integriteit kunnen worden gecontroleerd.",
|
||||
"assurance_verified_statement": "Aanvullende verificatie volgens een gedocumenteerde procedure is als bewijsgrondslag aangegeven.",
|
||||
},
|
||||
"pt": {
|
||||
"field_assurance": "Base de evidência",
|
||||
"not_applicable": "Não aplicável",
|
||||
"not_specified": "Não indicado",
|
||||
"fact_assurance": "Base de evidência",
|
||||
"assurance_selfDeclared_description": "As informações são fornecidas pela própria pessoa ou organização responsável pela publicação.",
|
||||
"assurance_technicallyRecorded_description": "As informações foram registadas tecnicamente durante o processo de criação ou publicação.",
|
||||
"assurance_signed_description": "A declaração foi assinada digitalmente para permitir a verificação da origem e integridade.",
|
||||
"assurance_verified_description": "As informações foram adicionalmente verificadas segundo um procedimento documentado.",
|
||||
"assurance_selfDeclared_statement": "As informações sobre a utilização de IA baseiam-se numa declaração da pessoa ou organização responsável pela publicação.",
|
||||
"assurance_technicallyRecorded_statement": "É indicado como base de evidência um registo técnico durante o processo de criação ou publicação.",
|
||||
"assurance_signed_statement": "É indicada como base de evidência uma assinatura digital da declaração, permitindo verificar a origem e a integridade.",
|
||||
"assurance_verified_statement": "É indicada como base de evidência uma verificação adicional segundo um procedimento documentado.",
|
||||
},
|
||||
"pl": {
|
||||
"field_assurance": "Podstawa dowodowa",
|
||||
"not_applicable": "Nie dotyczy",
|
||||
"not_specified": "Nie wskazano",
|
||||
"fact_assurance": "Podstawa dowodowa",
|
||||
"assurance_selfDeclared_description": "Informacje zostały podane przez osobę lub organizację publikującą treść.",
|
||||
"assurance_technicallyRecorded_description": "Informacje zostały technicznie zarejestrowane w procesie tworzenia lub publikacji.",
|
||||
"assurance_signed_description": "Deklaracja została podpisana cyfrowo, co umożliwia sprawdzenie jej pochodzenia i integralności.",
|
||||
"assurance_verified_description": "Informacje zostały dodatkowo zweryfikowane zgodnie z udokumentowaną procedurą.",
|
||||
"assurance_selfDeclared_statement": "Informacje o użyciu AI opierają się na deklaracji osoby lub organizacji publikującej treść.",
|
||||
"assurance_technicallyRecorded_statement": "Jako podstawę dowodową wskazano techniczną rejestrację w procesie tworzenia lub publikacji.",
|
||||
"assurance_signed_statement": "Jako podstawę dowodową wskazano cyfrowy podpis deklaracji, umożliwiający sprawdzenie jej pochodzenia i integralności.",
|
||||
"assurance_verified_statement": "Jako podstawę dowodową wskazano dodatkową weryfikację zgodnie z udokumentowaną procedurą.",
|
||||
},
|
||||
}
|
||||
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)
|
||||
out.Presets = make(map[string]PresetText, len(in.Presets))
|
||||
for k, v := range in.Presets {
|
||||
out.Presets[k] = v
|
||||
}
|
||||
out.Extents = cloneMap(in.Extents)
|
||||
out.Components = cloneMap(in.Components)
|
||||
out.Reviews = cloneMap(in.Reviews)
|
||||
out.Activities = cloneMap(in.Activities)
|
||||
out.Assurances = cloneMap(in.Assurances)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func Supported(code string) bool {
|
||||
_, ok := catalogs[Normalize(code)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func Normalize(code string) string {
|
||||
code = strings.ToLower(strings.TrimSpace(code))
|
||||
if i := strings.IndexAny(code, "-_"); i >= 0 {
|
||||
code = code[:i]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func Resolve(explicit, acceptLanguage, fallback string) string {
|
||||
if code := Normalize(explicit); Supported(code) {
|
||||
return code
|
||||
}
|
||||
for _, part := range strings.Split(acceptLanguage, ",") {
|
||||
code := Normalize(strings.SplitN(part, ";", 2)[0])
|
||||
if Supported(code) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
if code := Normalize(fallback); Supported(code) {
|
||||
return code
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
func Get(code string) Locale {
|
||||
if l, ok := catalogs[Normalize(code)]; ok {
|
||||
return cloneLocale(l)
|
||||
}
|
||||
return cloneLocale(catalogs["en"])
|
||||
}
|
||||
|
||||
func Languages() []LanguageOption {
|
||||
out := make([]LanguageOption, 0, len(catalogs))
|
||||
for code, l := range catalogs {
|
||||
out = append(out, LanguageOption{Code: code, Name: l.Name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func ClientCatalogs() map[string]Locale {
|
||||
out := make(map[string]Locale, len(catalogs))
|
||||
for code, l := range catalogs {
|
||||
out[code] = cloneLocale(l)
|
||||
}
|
||||
return out
|
||||
}
|
||||
22
internal/i18n/marketing.go
Normal file
22
internal/i18n/marketing.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package i18n
|
||||
|
||||
func init() {
|
||||
labels := map[string]string{
|
||||
"de": "Produkt & Installation",
|
||||
"en": "Product & installation",
|
||||
"fr": "Produit & installation",
|
||||
"es": "Producto e instalación",
|
||||
"it": "Prodotto e installazione",
|
||||
"nl": "Product & installatie",
|
||||
"pt": "Produto e instalação",
|
||||
"pl": "Produkt i instalacja",
|
||||
}
|
||||
for code, label := range labels {
|
||||
locale := catalogs[code]
|
||||
if locale.Text == nil {
|
||||
locale.Text = map[string]string{}
|
||||
}
|
||||
locale.Text["nav_product"] = label
|
||||
catalogs[code] = locale
|
||||
}
|
||||
}
|
||||
247
internal/licensing/licensing.go
Normal file
247
internal/licensing/licensing.go
Normal file
@@ -0,0 +1,247 @@
|
||||
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
|
||||
}
|
||||
63
internal/licensing/licensing_test.go
Normal file
63
internal/licensing/licensing_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
601
internal/marketing/content.go
Normal file
601
internal/marketing/content.go
Normal file
@@ -0,0 +1,601 @@
|
||||
package marketing
|
||||
|
||||
import "strings"
|
||||
|
||||
type Feature struct {
|
||||
Kicker string
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
type ComparisonRow struct {
|
||||
Feature string
|
||||
Community string
|
||||
Pro string
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Name string
|
||||
Price string
|
||||
Period string
|
||||
Description string
|
||||
Features []string
|
||||
CTA string
|
||||
URL string
|
||||
Featured bool
|
||||
}
|
||||
|
||||
type InstallMethod struct {
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Code string
|
||||
}
|
||||
|
||||
type ConfigRow struct {
|
||||
Name string
|
||||
Default string
|
||||
Description string
|
||||
}
|
||||
|
||||
type FAQ struct {
|
||||
Question string
|
||||
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
|
||||
}
|
||||
|
||||
type copySet struct {
|
||||
MetaDescription string
|
||||
NavFeatures, NavPricing, NavInstall, NavGenerator, NavBackground, LanguageLabel string
|
||||
HeroEyebrow, HeroTitle, HeroLead, PrimaryCTA, SecondaryCTA string
|
||||
Proof []string
|
||||
FeaturesEyebrow, FeaturesTitle, FeaturesLead string
|
||||
Features []Feature
|
||||
CompareEyebrow, CompareTitle, CompareLead, CompareFeature, CompareCommunity, ComparePro string
|
||||
Comparison []ComparisonRow
|
||||
PricingEyebrow, PricingTitle, PricingLead, PricePeriod, PriceNote string
|
||||
PlanDescriptions [5]string
|
||||
PlanFeatures [5][]string
|
||||
PlanCTA [5]string
|
||||
InstallEyebrow, InstallTitle, InstallLead, CopyLabel, CopiedLabel string
|
||||
InstallTitles [5]string
|
||||
InstallSummaries [5]string
|
||||
ConfigTitle, ConfigVariable, ConfigDefault, ConfigMeaning string
|
||||
ConfigDescriptions [8]string
|
||||
FAQEyebrow, FAQTitle string
|
||||
FAQs []FAQ
|
||||
FinalTitle, FinalLead, FinalPrimary, FinalSecondary, Footer string
|
||||
EnterprisePrice string
|
||||
}
|
||||
|
||||
func Build(lang, productName, baseURL, salesURL, contactURL string, prices Prices) Page {
|
||||
lang = normalize(lang)
|
||||
copy := copies()[lang]
|
||||
if copy.HeroTitle == "" {
|
||||
lang = "en"
|
||||
copy = copies()[lang]
|
||||
}
|
||||
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.1 .
|
||||
docker run -d --name ai-disclosure \
|
||||
-p 8080:8080 \
|
||||
-e BASE_URL=https://ai.example.org \
|
||||
-e PUBLIC_NAME="AI Usage Disclosure" \
|
||||
--read-only --tmpfs /tmp \
|
||||
ai-disclosure-standard:1.6.1`},
|
||||
{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='...'
|
||||
kubectl apply -f deploy/kubernetes.yaml
|
||||
kubectl rollout status deployment/ai-disclosure`},
|
||||
{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`},
|
||||
{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" \
|
||||
./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", "-"}
|
||||
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]})
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
FinalTitle: copy.FinalTitle, FinalLead: copy.FinalLead, FinalPrimary: copy.FinalPrimary, FinalSecondary: copy.FinalSecondary, Footer: copy.Footer,
|
||||
}
|
||||
}
|
||||
|
||||
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.", "Link zur Hintergrund- oder Kontaktseite.", "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.", "Background or contact 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 de contexte ou de contact.", "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 de contexto o contacto.", "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 di contesto o contatto.", "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.", "Achtergrond- of contactpagina.", "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 de contexto ou contacto.", "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.", "Strona informacji lub kontaktu.", "Język zapasowy.", "Token licencji podpisany przez dostawcę.", "Minimalny tryb: offline, hybrid lub online.", "Opcjonalny centralny serwer weryfikacji dla trybu hybrydowego i online."},
|
||||
}
|
||||
if descriptions, ok := values[normalize(lang)]; ok {
|
||||
return descriptions
|
||||
}
|
||||
return values["en"]
|
||||
}
|
||||
|
||||
func neutralize(c copySet, lang string) copySet {
|
||||
// The public product page documents the open software only. Commercial
|
||||
// pricing, edition comparisons and sales messaging intentionally stay out
|
||||
// of the application UI.
|
||||
if len(c.Features) > 4 {
|
||||
c.Features = append(append([]Feature{}, c.Features[:4]...), c.Features[5:]...)
|
||||
}
|
||||
if len(c.FAQs) > 2 {
|
||||
c.FAQs = c.FAQs[2:]
|
||||
}
|
||||
c.SecondaryCTA, c.FinalSecondary = "", ""
|
||||
switch lang {
|
||||
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.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.FinalTitle = "Direkt mit einer eigenen Erklärung starten."
|
||||
c.FinalLead = "Der Generator funktioniert ohne Registrierung, Cookies oder externe Ressourcen."
|
||||
case "fr":
|
||||
c.HeroEyebrow = "Open source · Auto-hébergeable"
|
||||
c.FeaturesLead = "Badges visibles, résumés compréhensibles et déclarations lisibles par machine dans une application légère."
|
||||
c.InstallLead = "Toutes les variantes utilisent le même binaire Go et fonctionnent sans service externe."
|
||||
case "es":
|
||||
c.HeroEyebrow = "Código abierto · Autoalojable"
|
||||
c.FeaturesLead = "Avisos visibles, resúmenes comprensibles y declaraciones legibles por máquina en una aplicación ligera."
|
||||
c.InstallLead = "Todas las variantes usan el mismo binario Go y funcionan sin servicios externos."
|
||||
case "it":
|
||||
c.HeroEyebrow = "Open source · Self-hosted"
|
||||
c.FeaturesLead = "Avvisi visibili, riepiloghi comprensibili e dichiarazioni leggibili dalle macchine in un'applicazione leggera."
|
||||
c.InstallLead = "Tutte le varianti usano lo stesso binario Go e funzionano senza servizi esterni."
|
||||
case "nl":
|
||||
c.HeroEyebrow = "Open source · Zelf te hosten"
|
||||
c.FeaturesLead = "Zichtbare meldingen, begrijpelijke samenvattingen en machineleesbare verklaringen in één lichte toepassing."
|
||||
c.InstallLead = "Alle varianten gebruiken dezelfde Go-binary en werken zonder externe diensten."
|
||||
case "pt":
|
||||
c.HeroEyebrow = "Código aberto · Autoalojado"
|
||||
c.FeaturesLead = "Avisos visíveis, resumos compreensíveis e declarações legíveis por máquina numa aplicação leve."
|
||||
c.InstallLead = "Todas as variantes usam o mesmo binário Go e funcionam sem serviços externos."
|
||||
case "pl":
|
||||
c.HeroEyebrow = "Open source · Samodzielny hosting"
|
||||
c.FeaturesLead = "Widoczne oznaczenia, zrozumiałe podsumowania i deklaracje maszynowe w lekkiej aplikacji."
|
||||
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.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 c
|
||||
}
|
||||
|
||||
func normalize(lang string) string {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
if i := strings.IndexAny(lang, "-_"); i >= 0 {
|
||||
lang = lang[:i]
|
||||
}
|
||||
return lang
|
||||
}
|
||||
|
||||
func copies() map[string]copySet {
|
||||
return map[string]copySet{
|
||||
"de": german(), "en": english(), "fr": french(), "es": spanish(),
|
||||
"it": italian(), "nl": dutch(), "pt": portuguese(), "pl": polish(),
|
||||
}
|
||||
}
|
||||
|
||||
func commonComparison(lang, yes, no, included, proOnly string) []ComparisonRow {
|
||||
features := map[string][]string{
|
||||
"de": {"SVG-Badges", "HTML-Erklärungseiten", "JSON-LD und Schema-Validierung", "8 Sprachen", "Docker, Kubernetes und Swarm", "Eigene Erklärungstexte", "Eigene Badge-Texte und Farben", "Signierte Offline-Lizenz", "Domaingebundene Aktivierung", "Kommerzieller Support"},
|
||||
"en": {"SVG badges", "HTML declaration pages", "JSON-LD & schema validation", "8 languages", "Docker, Kubernetes & Swarm", "Custom declaration copy", "Custom badge labels & colours", "Offline signed licence", "Domain-bound activation", "Commercial support"},
|
||||
"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"},
|
||||
"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"},
|
||||
}
|
||||
labels := features[normalize(lang)]
|
||||
if len(labels) == 0 {
|
||||
labels = features["en"]
|
||||
}
|
||||
community := []string{included, included, included, included, included, no, no, no, no, no}
|
||||
pro := []string{included, included, included, included, included, yes, yes, yes, yes, proOnly}
|
||||
rows := make([]ComparisonRow, 0, len(labels))
|
||||
for i, feature := range labels {
|
||||
rows = append(rows, ComparisonRow{Feature: feature, Community: community[i], Pro: pro[i]})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
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.",
|
||||
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.",
|
||||
Features: []Feature{
|
||||
{"Sichtbar", "SVG-Badges", "Deterministische, cachefähige Badges aus Presets oder strukturierten Parametern."},
|
||||
{"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."},
|
||||
{"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"),
|
||||
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{
|
||||
{"Standard-Presets", "Alle 8 Sprachen", "SVG, HTML und JSON-LD", "Self-Hosting"},
|
||||
{"Bis zu 3 Domains", "Eigene Texte", "Eigene Badge-Texte und Farben", "Signierte Offline-Lizenz"},
|
||||
{"Bis zu 20 Domains", "Alle Pro-Funktionen", "Priorisierter Support", "Migrationshilfe"},
|
||||
{"Bis zu 100 Domains", "Kundendomains", "Kommerzielle Agenturnutzung", "Technisches Onboarding"},
|
||||
{"Individuelle Domainzahl", "SLA und Supportfenster", "Private Distribution", "Individuelle Lizenzbedingungen"},
|
||||
},
|
||||
PlanCTA: [5]string{"Kostenlos starten", "Pro anfragen", "Publisher anfragen", "Agency anfragen", "Kontakt aufnehmen"}, EnterprisePrice: "Individuell",
|
||||
InstallEyebrow: "Deployment", InstallTitle: "In wenigen Befehlen produktiv.", InstallLead: "Alle Varianten verwenden dasselbe Go-Binary. Community und Pro unterscheiden sich nur durch optionale Lizenz-Secrets.", CopyLabel: "Kopieren", CopiedLabel: "Kopiert",
|
||||
InstallTitles: [5]string{"Docker Compose", "Docker CLI", "Kubernetes", "Docker Swarm", "Direkt mit Go"},
|
||||
InstallSummaries: [5]string{"Lokaler oder einzelner Server mit deklarativer Konfiguration.", "Image selbst bauen und als gehärteten Container starten.", "Drei Replikate, Probes, Rolling Updates, HPA und PodDisruptionBudget.", "Mehrere Replikate mit Docker-nativem Orchestrator betreiben.", "Für Entwicklung, Tests oder ein natives Systemd-Deployment."},
|
||||
ConfigTitle: "Wichtige Umgebungsvariablen", ConfigVariable: "Variable", ConfigDefault: "Standard", ConfigMeaning: "Bedeutung",
|
||||
ConfigDescriptions: [8]string{"Bind-Adresse des HTTP-Servers.", "Öffentliche Basis-URL ohne abschließenden Slash.", "Produkt- oder Seitentitel.", "Link zur Hintergrund- oder Kontaktseite.", "Ziel für Pro- und Vertriebsanfragen.", "Rückfallsprache der Ausgabe.", "Öffentlicher Ed25519-Schlüssel für Pro.", "Signierter und optional domaingebundener Pro-Token."},
|
||||
FAQEyebrow: "FAQ", FAQTitle: "Häufige Fragen",
|
||||
FAQs: []FAQ{
|
||||
{"Ist die Community-Ausgabe eingeschränkt?", "Die Standard-Presets, acht Sprachen, SVG, Erklärungseiten, JSON-LD, Validator und alle Deployment-Dateien sind vollständig nutzbar. Nur eigene Texte, Farben und kommerzielle Zusatzleistungen benötigen Pro."},
|
||||
{"Benötigt Pro eine Verbindung zu einem Lizenzserver?", "Nein. Die Ed25519-Signatur wird lokal in jeder Replik geprüft. Dadurch bleibt das Deployment hochverfügbar und funktioniert auch in abgeschotteten Netzen."},
|
||||
{"Kann ich das System hinter einem CDN betreiben?", "Ja. Badge- und Manifest-Antworten sind deterministisch, senden ETags und geeignete Cache-Control-Header."},
|
||||
{"Ist die Kennzeichnung automatisch rechtskonform?", "Nein. Der Dienst stellt technische Deklarationen und Integrationen bereit, ersetzt aber keine rechtliche Prüfung oder redaktionelle Verantwortung."},
|
||||
},
|
||||
FinalTitle: "Starte offen. Erweitere nur dort, wo deine Marke es braucht.", FinalLead: "Der Generator ist ohne Registrierung nutzbar. Für eigene Texte, Farben und Domains steht die Pro-Lizenz bereit.", FinalPrimary: "Generator öffnen", FinalSecondary: "Pro besprechen", Footer: "Offener KI-Nutzungsstandard · Keine Rechtsberatung.",
|
||||
}
|
||||
}
|
||||
|
||||
func english() copySet {
|
||||
return copySet{
|
||||
MetaDescription: "Product, pricing and installation for the open AI usage disclosure standard.",
|
||||
NavFeatures: "Features", NavPricing: "Pricing", NavInstall: "Installation", NavGenerator: "Generator", NavBackground: "Background", LanguageLabel: "Language",
|
||||
HeroEyebrow: "Open source core · Pro when needed", HeroTitle: "Disclose AI use without locking into a platform.", HeroLead: "A stateless Go service for SVG badges, human-readable declaration pages and JSON-LD. Self-hostable, internationalised and built for highly available deployments.",
|
||||
PrimaryCTA: "Create a badge", SecondaryCTA: "Talk about Pro", Proof: []string{"8 languages", "No cookies", "No database", "Docker & Kubernetes"},
|
||||
FeaturesEyebrow: "Feature set", FeaturesTitle: "From a visible notice to machine-readable evidence.", FeaturesLead: "Community covers the open integration. Pro adds branded presentation and commercial licensing.",
|
||||
Features: []Feature{
|
||||
{"Visible", "SVG badges", "Deterministic, cacheable badges generated from presets or structured parameters."},
|
||||
{"Readable", "Declaration pages", "Human-readable pages explain AI contribution, activities, review and responsibility."},
|
||||
{"Machine-readable", "JSON-LD and schema", "Link, validate or include manifests in build and CMS workflows."},
|
||||
{"International", "Eight languages", "German, English, French, Spanish, Italian, Dutch, Portuguese and Polish."},
|
||||
{"Pro", "Custom copy and design", "Custom titles, descriptions, badge labels and colours with server-side enforcement."},
|
||||
{"Offline", "Signed licensing", "Ed25519 licences are verified locally without an external licensing service."},
|
||||
{"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"),
|
||||
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"}},
|
||||
PlanCTA: [5]string{"Start free", "Contact Pro", "Contact Publisher", "Contact Agency", "Contact sales"}, EnterprisePrice: "Custom",
|
||||
InstallEyebrow: "Deployment", InstallTitle: "Production-ready in a few commands.", InstallLead: "Every option runs the same Go binary. Community and Pro differ only through optional licence secrets.", CopyLabel: "Copy", CopiedLabel: "Copied",
|
||||
InstallTitles: [5]string{"Docker Compose", "Docker CLI", "Kubernetes", "Docker Swarm", "Run with Go"}, InstallSummaries: [5]string{"Declarative setup for a local or single-server deployment.", "Build the image and run a hardened container directly.", "Three replicas, probes, rolling updates, HPA and a PodDisruptionBudget.", "Operate several replicas with Docker's native orchestrator.", "For development, tests or a native systemd deployment."},
|
||||
ConfigTitle: "Important environment variables", ConfigVariable: "Variable", ConfigDefault: "Default", ConfigMeaning: "Purpose", ConfigDescriptions: [8]string{"HTTP server bind address.", "Public base URL without a trailing slash.", "Product or site title.", "Background or contact page.", "Destination for Pro and sales enquiries.", "Fallback output language.", "Public Ed25519 key for Pro verification.", "Signed and optionally domain-bound Pro token."},
|
||||
FAQEyebrow: "FAQ", FAQTitle: "Common questions", FAQs: []FAQ{{"Is Community artificially limited?", "No. Standard presets, eight languages, SVG, declaration pages, JSON-LD, validation and deployment files are fully usable. Only custom presentation and commercial services require Pro."}, {"Does Pro depend on a licensing server?", "No. Every replica verifies the Ed25519 signature locally, preserving availability and supporting isolated networks."}, {"Can I place the service behind a CDN?", "Yes. Badge and manifest responses are deterministic and include ETag and cache-control headers."}, {"Does this automatically make a site legally compliant?", "No. The service provides technical declarations and integrations; it does not replace legal review or editorial responsibility."}},
|
||||
FinalTitle: "Start open. Add branding only where you need it.", FinalLead: "The generator works without registration. Pro is available for custom copy, colours and licensed domains.", FinalPrimary: "Open generator", FinalSecondary: "Discuss Pro", Footer: "Open AI usage disclosure standard · Not legal advice.",
|
||||
}
|
||||
}
|
||||
|
||||
func french() copySet {
|
||||
c := english()
|
||||
c.MetaDescription = "Produit, tarifs et installation de la norme ouverte de déclaration d'utilisation de l'IA."
|
||||
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Fonctions", "Tarifs", "Installation", "Générateur", "Contexte", "Langue"
|
||||
c.HeroEyebrow, c.HeroTitle = "Cœur open source · Pro si nécessaire", "Déclarez l'usage de l'IA sans dépendre d'une plateforme."
|
||||
c.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.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.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.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 de contexte ou de contact.", "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.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."
|
||||
return c
|
||||
}
|
||||
|
||||
func spanish() copySet {
|
||||
c := english()
|
||||
c.MetaDescription = "Producto, precios e instalación del estándar abierto de declaración de uso de IA."
|
||||
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Funciones", "Precios", "Instalación", "Generador", "Contexto", "Idioma"
|
||||
c.HeroEyebrow, c.HeroTitle = "Núcleo open source · Pro cuando haga falta", "Declara el uso de IA sin depender de una plataforma."
|
||||
c.HeroLead = "Un servicio Go sin estado para insignias SVG, páginas explicativas y JSON-LD. Autoalojable, internacional y preparado para alta disponibilidad."
|
||||
c.PrimaryCTA, c.SecondaryCTA = "Crear una insignia", "Consultar Pro"
|
||||
c.Proof = []string{"8 idiomas", "Sin cookies", "Sin base de datos", "Docker y Kubernetes"}
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funciones", "Desde el aviso visible hasta la declaración legible por máquina.", "Community cubre la integración abierta. Pro añade personalización y licencia comercial."
|
||||
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.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."}
|
||||
c.PlanFeatures = [5][]string{{"Preajustes estándar", "8 idiomas", "SVG, HTML y JSON-LD", "Autoalojamiento"}, {"Hasta 3 dominios", "Textos propios", "Etiquetas y colores propios", "Licencia offline firmada"}, {"Hasta 20 dominios", "Todas las funciones Pro", "Soporte prioritario", "Ayuda de migración"}, {"Hasta 100 dominios", "Dominios de clientes", "Uso comercial de agencia", "Onboarding técnico"}, {"Dominios a medida", "SLA y ventanas de soporte", "Distribución privada", "Condiciones de licencia propias"}}
|
||||
c.PlanCTA = [5]string{"Empezar gratis", "Consultar Pro", "Consultar Publisher", "Consultar Agency", "Contactar ventas"}
|
||||
c.EnterprisePrice = "A medida"
|
||||
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Despliegue", "Listo para producción en pocos comandos.", "Todas las opciones ejecutan el mismo binario Go. Community y Pro solo se diferencian por secretos de licencia opcionales."
|
||||
c.CopyLabel, c.CopiedLabel = "Copiar", "Copiado"
|
||||
c.InstallSummaries = [5]string{"Configuración declarativa local o de un servidor.", "Construye la imagen y ejecuta un contenedor endurecido.", "Tres réplicas, probes, rolling updates, HPA y PodDisruptionBudget.", "Varias réplicas con el orquestador nativo de Docker.", "Para desarrollo, pruebas o un despliegue nativo con systemd."}
|
||||
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variables de entorno importantes", "Variable", "Valor por defecto", "Uso"
|
||||
c.ConfigDescriptions = [8]string{"Dirección de escucha HTTP.", "URL pública sin barra final.", "Título del producto o sitio.", "Página de contexto o contacto.", "Destino de consultas Pro y comerciales.", "Idioma de respaldo.", "Clave pública Ed25519 para Pro.", "Token Pro firmado y opcionalmente ligado a dominios."}
|
||||
c.FAQEyebrow, c.FAQTitle = "FAQ", "Preguntas frecuentes"
|
||||
c.FAQs = []FAQ{{"¿Community está limitada artificialmente?", "No. Los preajustes, ocho idiomas, SVG, páginas, JSON-LD, validación y archivos de despliegue son completos. Solo la personalización comercial requiere Pro."}, {"¿Pro depende de un servidor de licencias?", "No. Cada réplica verifica localmente la firma Ed25519."}, {"¿Puedo usar un CDN?", "Sí. Las respuestas son deterministas e incluyen ETag y Cache-Control."}, {"¿Garantiza cumplimiento legal?", "No. Es infraestructura técnica y no sustituye revisión jurídica ni responsabilidad editorial."}}
|
||||
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Empieza abierto. Añade marca solo cuando la necesites.", "El generador funciona sin registro. Pro añade textos, colores y dominios personalizados.", "Abrir generador", "Hablar de Pro"
|
||||
c.Footer = "Estándar abierto de declaración de uso de IA · No es asesoramiento jurídico."
|
||||
return c
|
||||
}
|
||||
|
||||
func italian() copySet {
|
||||
c := english()
|
||||
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.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.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.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"}}
|
||||
c.PlanCTA = [5]string{"Inizia gratis", "Contatta Pro", "Contatta Publisher", "Contatta Agency", "Contatta vendite"}
|
||||
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.ConfigDescriptions = [8]string{"Indirizzo di ascolto HTTP.", "URL pubblico senza slash finale.", "Titolo del prodotto o sito.", "Pagina di contesto o contatto.", "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."
|
||||
return c
|
||||
}
|
||||
|
||||
func dutch() copySet {
|
||||
c := english()
|
||||
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.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.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.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.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.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.", "Achtergrond- of contactpagina.", "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.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
|
||||
}
|
||||
|
||||
func portuguese() copySet {
|
||||
c := english()
|
||||
c.MetaDescription = "Produto, preços e instalação do padrão aberto de declaração de uso de IA."
|
||||
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Funcionalidades", "Preços", "Instalação", "Gerador", "Contexto", "Idioma"
|
||||
c.HeroEyebrow, c.HeroTitle = "Núcleo open source · Pro quando necessário", "Declare o uso de IA sem dependência de plataforma."
|
||||
c.HeroLead = "Um serviço Go sem estado para badges SVG, páginas explicativas e JSON-LD. Autoalojável, internacional e preparado para alta disponibilidade."
|
||||
c.PrimaryCTA, c.SecondaryCTA = "Criar badge", "Falar sobre Pro"
|
||||
c.Proof = []string{"8 idiomas", "Sem cookies", "Sem base de dados", "Docker e Kubernetes"}
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funcionalidades", "Do aviso visível à declaração legível por máquina.", "Community cobre a integração aberta. Pro adiciona personalização e licenciamento comercial."
|
||||
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.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."}
|
||||
c.PlanFeatures = [5][]string{{"Presets padrão", "8 idiomas", "SVG, HTML e JSON-LD", "Autoalojamento"}, {"Até 3 domínios", "Textos próprios", "Etiquetas e cores próprias", "Licença offline assinada"}, {"Até 20 domínios", "Todas as funções Pro", "Suporte prioritário", "Ajuda de migração"}, {"Até 100 domínios", "Domínios de clientes", "Uso comercial por agência", "Onboarding técnico"}, {"Domínios personalizados", "SLA e janelas de suporte", "Distribuição privada", "Condições personalizadas"}}
|
||||
c.PlanCTA = [5]string{"Começar grátis", "Contactar Pro", "Contactar Publisher", "Contactar Agency", "Contactar vendas"}
|
||||
c.EnterprisePrice = "Personalizado"
|
||||
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Deployment", "Pronto para produção em poucos comandos.", "Todas as opções executam o mesmo binário Go. Community e Pro diferem apenas por secrets de licença opcionais."
|
||||
c.CopyLabel, c.CopiedLabel = "Copiar", "Copiado"
|
||||
c.InstallSummaries = [5]string{"Configuração declarativa local ou num único servidor.", "Construa a imagem e execute um contentor hardened.", "Três réplicas, probes, rolling updates, HPA e PodDisruptionBudget.", "Várias réplicas com Docker Swarm.", "Para desenvolvimento, testes ou deployment nativo com systemd."}
|
||||
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Variáveis de ambiente importantes", "Variável", "Padrão", "Objetivo"
|
||||
c.ConfigDescriptions = [8]string{"Endereço de escuta HTTP.", "URL pública sem barra final.", "Título do produto ou site.", "Página de contexto ou contacto.", "Destino para pedidos Pro e comerciais.", "Idioma de fallback.", "Chave pública Ed25519 para Pro.", "Token Pro assinado e opcionalmente ligado a domínios."}
|
||||
c.FAQEyebrow, c.FAQTitle = "FAQ", "Perguntas frequentes"
|
||||
c.FAQs = []FAQ{{"Community é limitada artificialmente?", "Não. Presets, oito idiomas, SVG, páginas, JSON-LD, validação e ficheiros de deployment estão completos. Apenas a personalização comercial exige Pro."}, {"Pro depende de um servidor de licenças?", "Não. Cada réplica valida localmente a assinatura Ed25519."}, {"Posso usar um CDN?", "Sim. Badges e manifestos são determinísticos e incluem ETag e Cache-Control."}, {"Isto garante conformidade legal?", "Não. É infraestrutura técnica e não substitui análise jurídica ou responsabilidade editorial."}}
|
||||
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Comece aberto. Adicione a marca apenas quando necessário.", "O gerador funciona sem registo. Pro adiciona textos, cores e domínios personalizados.", "Abrir gerador", "Falar sobre Pro"
|
||||
c.Footer = "Padrão aberto de declaração de uso de IA · Não é aconselhamento jurídico."
|
||||
return c
|
||||
}
|
||||
|
||||
func polish() copySet {
|
||||
c := english()
|
||||
c.MetaDescription = "Produkt, ceny i instalacja otwartego standardu deklarowania użycia AI."
|
||||
c.NavFeatures, c.NavPricing, c.NavInstall, c.NavGenerator, c.NavBackground, c.LanguageLabel = "Funkcje", "Cennik", "Instalacja", "Generator", "Informacje", "Język"
|
||||
c.HeroEyebrow, c.HeroTitle = "Rdzeń open source · Pro w razie potrzeby", "Deklaruj użycie AI bez uzależnienia od platformy."
|
||||
c.HeroLead = "Bezstanowa usługa Go dla plakietek SVG, zrozumiałych stron deklaracji i JSON-LD. Samodzielny hosting, wiele języków i wysoka dostępność."
|
||||
c.PrimaryCTA, c.SecondaryCTA = "Utwórz plakietkę", "Zapytaj o Pro"
|
||||
c.Proof = []string{"8 języków", "Bez cookies", "Bez bazy danych", "Docker i Kubernetes"}
|
||||
c.FeaturesEyebrow, c.FeaturesTitle, c.FeaturesLead = "Funkcje", "Od widocznej informacji do deklaracji czytelnej maszynowo.", "Community zapewnia otwartą integrację. Pro dodaje personalizację i licencję komercyjną."
|
||||
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.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ń."}
|
||||
c.PlanFeatures = [5][]string{{"Standardowe presety", "8 języków", "SVG, HTML i JSON-LD", "Self-hosting"}, {"Do 3 domen", "Własne teksty", "Własne etykiety i kolory", "Podpisana licencja offline"}, {"Do 20 domen", "Wszystkie funkcje Pro", "Priorytetowe wsparcie", "Pomoc w migracji"}, {"Do 100 domen", "Domeny klientów", "Komercyjne użycie agencyjne", "Onboarding techniczny"}, {"Indywidualna liczba domen", "SLA i okna wsparcia", "Prywatna dystrybucja", "Indywidualne warunki"}}
|
||||
c.PlanCTA = [5]string{"Zacznij bezpłatnie", "Zapytaj o Pro", "Zapytaj o Publisher", "Zapytaj o Agency", "Kontakt ze sprzedażą"}
|
||||
c.EnterprisePrice = "Indywidualnie"
|
||||
c.InstallEyebrow, c.InstallTitle, c.InstallLead = "Wdrożenie", "Gotowe do produkcji w kilku poleceniach.", "Każda opcja uruchamia ten sam plik Go. Community i Pro różnią się tylko opcjonalnymi sekretami licencji."
|
||||
c.CopyLabel, c.CopiedLabel = "Kopiuj", "Skopiowano"
|
||||
c.InstallSummaries = [5]string{"Deklaratywna konfiguracja lokalna lub na jednym serwerze.", "Zbuduj obraz i uruchom utwardzony kontener.", "Trzy repliki, probes, rolling updates, HPA i PodDisruptionBudget.", "Wiele replik z Docker Swarm.", "Do developmentu, testów lub natywnego wdrożenia systemd."}
|
||||
c.ConfigTitle, c.ConfigVariable, c.ConfigDefault, c.ConfigMeaning = "Ważne zmienne środowiskowe", "Zmienna", "Domyślnie", "Znaczenie"
|
||||
c.ConfigDescriptions = [8]string{"Adres nasłuchiwania HTTP.", "Publiczny URL bazowy bez końcowego ukośnika.", "Nazwa produktu lub strony.", "Strona informacji lub kontaktu.", "Cel zapytań Pro i sprzedażowych.", "Język zapasowy.", "Publiczny klucz Ed25519 dla Pro.", "Podpisany i opcjonalnie związany z domeną token Pro."}
|
||||
c.FAQEyebrow, c.FAQTitle = "FAQ", "Częste pytania"
|
||||
c.FAQs = []FAQ{{"Czy Community jest sztucznie ograniczona?", "Nie. Presety, osiem języków, SVG, strony, JSON-LD, walidacja i pliki wdrożeniowe są kompletne. Tylko personalizacja komercyjna wymaga Pro."}, {"Czy Pro wymaga serwera licencji?", "Nie. Każda replika lokalnie weryfikuje podpis Ed25519."}, {"Czy mogę użyć CDN?", "Tak. Plakietki i manifesty są deterministyczne i używają ETag oraz Cache-Control."}, {"Czy to gwarantuje zgodność prawną?", "Nie. To infrastruktura techniczna, która nie zastępuje analizy prawnej ani odpowiedzialności redakcyjnej."}}
|
||||
c.FinalTitle, c.FinalLead, c.FinalPrimary, c.FinalSecondary = "Zacznij otwarcie. Dodaj markę tylko tam, gdzie jej potrzebujesz.", "Generator działa bez rejestracji. Pro dodaje własne teksty, kolory i domeny.", "Otwórz generator", "Porozmawiaj o Pro"
|
||||
c.Footer = "Otwarty standard deklarowania użycia AI · To nie jest porada prawna."
|
||||
return c
|
||||
}
|
||||
25
internal/marketing/content_test.go
Normal file
25
internal/marketing/content_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package marketing
|
||||
|
||||
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 == "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
74
openapi-license-server.yaml
Normal file
74
openapi-license-server.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Universal Licence Server API
|
||||
version: 1.5.0
|
||||
description: Optional introspection, registration, revocation and signed lease API.
|
||||
paths:
|
||||
/v1/introspect:
|
||||
post:
|
||||
summary: Validate a registered licence and issue a signed short-lived lease
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [token, product, baseUrl]
|
||||
properties:
|
||||
token: {type: string}
|
||||
product: {type: string}
|
||||
baseUrl: {type: string, format: uri}
|
||||
instanceId: {type: string}
|
||||
clientVersion: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Valid licence and signed lease
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
valid: {const: true}
|
||||
leaseToken: {type: string}
|
||||
expiresAt: {type: string, format: date-time}
|
||||
"403": {description: Invalid, unregistered or revoked licence}
|
||||
/v1/admin/licenses:
|
||||
get:
|
||||
summary: List registered licences
|
||||
security: [{bearerAuth: []}]
|
||||
responses: {"200": {description: Registry records}}
|
||||
post:
|
||||
summary: Register or replace a signed licence
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [token]
|
||||
properties: {token: {type: string}}
|
||||
responses: {"201": {description: Registered}}
|
||||
/v1/admin/licenses/{licenseId}/revoke:
|
||||
post:
|
||||
summary: Revoke a registered licence
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {name: licenseId, in: path, required: true, schema: {type: string}}
|
||||
responses: {"200": {description: Revoked}}
|
||||
/v1/admin/licenses/{licenseId}/restore:
|
||||
post:
|
||||
summary: Restore a registered licence
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {name: licenseId, in: path, required: true, schema: {type: string}}
|
||||
responses: {"200": {description: Restored}}
|
||||
/healthz:
|
||||
get:
|
||||
summary: Health check
|
||||
responses: {"200": {description: Healthy}}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
207
openapi.yaml
Normal file
207
openapi.yaml
Normal file
@@ -0,0 +1,207 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AI Usage Disclosure API
|
||||
version: 1.6.1
|
||||
description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities.
|
||||
servers:
|
||||
- url: https://ai.example.org
|
||||
paths:
|
||||
/badge/{preset}.svg:
|
||||
get:
|
||||
summary: Render a standard preset badge
|
||||
parameters:
|
||||
- name: preset
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string, enum: [no-ai, research, summary, full]}
|
||||
- {$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.}}
|
||||
responses:
|
||||
"200":
|
||||
description: SVG badge
|
||||
content:
|
||||
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/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
|
||||
parameters:
|
||||
- {$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/assurance'}
|
||||
- {$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: Human-readable HTML declaration}
|
||||
"403": {$ref: '#/components/responses/ProRequired'}
|
||||
/v1/declaration.json:
|
||||
get:
|
||||
summary: Generate a JSON-LD declaration from query parameters
|
||||
parameters:
|
||||
- {$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/customTitle'}
|
||||
- {$ref: '#/components/parameters/customDescription'}
|
||||
- {$ref: '#/components/parameters/badgeLabel'}
|
||||
- {$ref: '#/components/parameters/badgeMessage'}
|
||||
- {$ref: '#/components/parameters/leftColor'}
|
||||
- {$ref: '#/components/parameters/rightColor'}
|
||||
responses:
|
||||
"200":
|
||||
description: JSON-LD declaration
|
||||
content:
|
||||
application/ld+json:
|
||||
schema: {$ref: './schema/declaration.schema.json'}
|
||||
"403": {$ref: '#/components/responses/ProRequired'}
|
||||
/v1/validate:
|
||||
post:
|
||||
summary: Validate an AI usage declaration
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: './schema/declaration.schema.json'}
|
||||
responses:
|
||||
"200": {description: Valid declaration}
|
||||
"422": {description: Validation failed}
|
||||
/v1/capabilities:
|
||||
get:
|
||||
summary: Return edition, licensed features and supported languages
|
||||
responses:
|
||||
"200":
|
||||
description: Runtime capabilities
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
license: {type: object}
|
||||
supportedLanguages:
|
||||
type: array
|
||||
items: {type: string}
|
||||
/healthz:
|
||||
get:
|
||||
summary: Liveness endpoint
|
||||
responses: {"200": {description: Healthy}}
|
||||
/readyz:
|
||||
get:
|
||||
summary: Readiness endpoint
|
||||
responses: {"200": {description: Ready}}
|
||||
components:
|
||||
parameters:
|
||||
preset:
|
||||
name: preset
|
||||
in: query
|
||||
schema: {type: string, enum: [no-ai, research, summary, full]}
|
||||
extent:
|
||||
name: extent
|
||||
in: query
|
||||
schema: {type: string, enum: [none, assisted, partial, mostly, full], default: assisted}
|
||||
language:
|
||||
name: lang
|
||||
in: query
|
||||
schema: {type: string, enum: [de, en, fr, es, it, nl, pt, pl]}
|
||||
component:
|
||||
name: component
|
||||
in: query
|
||||
schema: {type: string, enum: [text, coverImage, image, audio, video, code, other], default: text}
|
||||
activities:
|
||||
name: activities
|
||||
in: query
|
||||
schema: {type: string, description: Comma-separated activity identifiers.}
|
||||
review:
|
||||
name: review
|
||||
in: query
|
||||
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}
|
||||
theme:
|
||||
name: theme
|
||||
in: query
|
||||
schema: {type: string, enum: [mono, color], default: color}
|
||||
style:
|
||||
name: style
|
||||
in: query
|
||||
schema: {type: string, enum: [flat, flat-square], default: flat}
|
||||
customTitle:
|
||||
name: customTitle
|
||||
in: query
|
||||
description: Licensed feature custom_text
|
||||
schema: {type: string, maxLength: 120}
|
||||
customDescription:
|
||||
name: customDescription
|
||||
in: query
|
||||
description: Licensed feature custom_text
|
||||
schema: {type: string, maxLength: 500}
|
||||
badgeLabel:
|
||||
name: badgeLabel
|
||||
in: query
|
||||
description: Licensed feature custom_badge
|
||||
schema: {type: string, maxLength: 40}
|
||||
badgeMessage:
|
||||
name: badgeMessage
|
||||
in: query
|
||||
description: Licensed feature custom_badge
|
||||
schema: {type: string, maxLength: 80}
|
||||
leftColor:
|
||||
name: leftColor
|
||||
in: query
|
||||
description: Licensed feature custom_badge
|
||||
schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}
|
||||
rightColor:
|
||||
name: rightColor
|
||||
in: query
|
||||
description: Licensed feature custom_badge
|
||||
schema: {type: string, pattern: '^#[0-9A-Fa-f]{6}$'}
|
||||
responses:
|
||||
ProRequired:
|
||||
description: A valid licensed capability is required
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {const: pro_feature_required}
|
||||
status: {const: 403}
|
||||
detail: {type: string}
|
||||
391
pkg/licenseclient/client.go
Normal file
391
pkg/licenseclient/client.go
Normal file
@@ -0,0 +1,391 @@
|
||||
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
|
||||
}
|
||||
81
pkg/licenseclient/client_test.go
Normal file
81
pkg/licenseclient/client_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
4
pkg/licenseclient/doc.go
Normal file
4
pkg/licenseclient/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// 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
|
||||
3
pkg/licensekit/doc.go
Normal file
3
pkg/licensekit/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package licensekit implements product-neutral Ed25519 licence and lease
|
||||
// tokens, embedded trust stores, context validation and key rotation by key ID.
|
||||
package licensekit
|
||||
472
pkg/licensekit/licensekit.go
Normal file
472
pkg/licensekit/licensekit.go
Normal file
@@ -0,0 +1,472 @@
|
||||
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
|
||||
}
|
||||
84
pkg/licensekit/licensekit_test.go
Normal file
84
pkg/licensekit/licensekit_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
4
pkg/licenseserver/doc.go
Normal file
4
pkg/licenseserver/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// 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
|
||||
271
pkg/licenseserver/server.go
Normal file
271
pkg/licenseserver/server.go
Normal file
@@ -0,0 +1,271 @@
|
||||
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[:])
|
||||
}
|
||||
91
pkg/licenseserver/server_test.go
Normal file
91
pkg/licenseserver/server_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
130
pkg/licenseserver/store.go
Normal file
130
pkg/licenseserver/store.go
Normal file
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
35
run.ps1
Normal file
35
run.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$envFile = Join-Path $PSScriptRoot ".env"
|
||||
|
||||
if (-not (Test-Path $envFile)) {
|
||||
throw "Keine .env-Datei gefunden: $envFile"
|
||||
}
|
||||
|
||||
Get-Content $envFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
|
||||
if ($line -and -not $line.StartsWith("#")) {
|
||||
$parts = $line -split "=", 2
|
||||
|
||||
if ($parts.Count -eq 2) {
|
||||
$name = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
|
||||
if (
|
||||
($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||
($value.StartsWith("'") -and $value.EndsWith("'"))
|
||||
) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
|
||||
[Environment]::SetEnvironmentVariable(
|
||||
$name,
|
||||
$value,
|
||||
"Process"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go run .\cmd\server
|
||||
55
schema/declaration.schema.json
Normal file
55
schema/declaration.schema.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.org/schema/v1/declaration.schema.json",
|
||||
"title": "AI Usage Declaration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["@context", "@type", "schemaVersion", "language", "components", "assurance"],
|
||||
"properties": {
|
||||
"@context": {"type": "string"},
|
||||
"@type": {"const": "AIUsageDeclaration"},
|
||||
"schemaVersion": {"const": "1.1"},
|
||||
"subject": {"type": "string", "format": "uri"},
|
||||
"declaredAt": {"type": "string", "format": "date-time"},
|
||||
"language": {"enum": ["de", "en", "fr", "es", "it", "nl", "pt", "pl"]},
|
||||
"components": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/component"}},
|
||||
"editorialResponsibility": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"url": {"type": "string", "format": "uri"}
|
||||
}
|
||||
},
|
||||
"assurance": {"enum": ["selfDeclared", "technicallyRecorded", "signed", "verified"]},
|
||||
"presentation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": {"type": "string", "maxLength": 120},
|
||||
"description": {"type": "string", "maxLength": 500},
|
||||
"badgeLabel": {"type": "string", "maxLength": 40},
|
||||
"badgeMessage": {"type": "string", "maxLength": 80},
|
||||
"leftColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"},
|
||||
"rightColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"component": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["aiExtent", "humanReview"],
|
||||
"properties": {
|
||||
"aiExtent": {"enum": ["none", "assisted", "partial", "mostly", "full"]},
|
||||
"activities": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {"enum": ["research", "summarisation", "drafting", "generation", "translation", "editing", "imageGeneration", "codeGeneration", "transcription", "classification"]}
|
||||
},
|
||||
"humanReview": {"enum": ["none", "basic", "editorial", "expert"]},
|
||||
"note": {"type": "string", "maxLength": 500}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
schema/license.schema.json
Normal file
34
schema/license.schema.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$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"}}
|
||||
}
|
||||
}
|
||||
12
schema/trust-store.schema.json
Normal file
12
schema/trust-store.schema.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$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}}
|
||||
}
|
||||
}
|
||||
21
third_party/license-platform-client/LICENSE
vendored
Normal file
21
third_party/license-platform-client/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 AI Usage Disclosure contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
16
third_party/license-platform-client/README.md
vendored
Normal file
16
third_party/license-platform-client/README.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Universal License Platform — client snapshot
|
||||
|
||||
This directory contains only the runtime verification subset required by the
|
||||
AI Disclosure Standard. It is compatible with Universal License Platform
|
||||
v1.0.0 and intentionally excludes key generation, private-key decoding,
|
||||
license issuance, administrative APIs, storage and server code.
|
||||
|
||||
Source protocol: `github.com/b1tsblog/license-platform`
|
||||
|
||||
The product imports:
|
||||
|
||||
- `pkg/licensekit`: public-key trust-store parsing and token verification only;
|
||||
- `sdk/go/licenseclient`: offline, hybrid and online runtime checks.
|
||||
|
||||
The standalone Universal License Platform remains the sole authority for key
|
||||
management, license issuance, revocation and lease signing.
|
||||
3
third_party/license-platform-client/go.mod
vendored
Normal file
3
third_party/license-platform-client/go.mod
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/b1tsblog/license-platform
|
||||
|
||||
go 1.23
|
||||
3
third_party/license-platform-client/pkg/licensekit/doc.go
vendored
Normal file
3
third_party/license-platform-client/pkg/licensekit/doc.go
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package licensekit implements the verification-only token protocol consumed
|
||||
// by Universal License Platform client applications.
|
||||
package licensekit
|
||||
408
third_party/license-platform-client/pkg/licensekit/licensekit.go
vendored
Normal file
408
third_party/license-platform-client/pkg/licensekit/licensekit.go
vendored
Normal file
@@ -0,0 +1,408 @@
|
||||
// Package licensekit contains the verification-only protocol types and
|
||||
// cryptographic checks used by Universal License Platform clients.
|
||||
//
|
||||
// This client snapshot intentionally contains no private-key handling, token
|
||||
// signing, key generation, license issuance or administrative functionality.
|
||||
package licensekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"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"`
|
||||
ServerURL string `json:"serverUrl,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 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); 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); 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 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 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) 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 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")
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Verification.ServerURL); raw != "" {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return errors.New("verification server URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLeaseClaims(c LeaseClaims, now time.Time) 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
35
third_party/license-platform-client/pkg/licensekit/licensekit_test.go
vendored
Normal file
35
third_party/license-platform-client/pkg/licensekit/licensekit_test.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
package licensekit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureIssuerPublic = "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ"
|
||||
fixtureOfflineToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfb2ZmbGluZV9maXh0dXJlIiwiaXNzdWVyIjoidW5pdmVyc2FsLWxpY2Vuc2UtcGxhdGZvcm0iLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJwcm9kdWN0IjoiYWktZGlzY2xvc3VyZS1zdGFuZGFyZCIsImVkaXRpb24iOiJwcm8iLCJmZWF0dXJlcyI6WyJjdXN0b21fdGV4dCIsImN1c3RvbV9iYWRnZSJdLCJsaW1pdHMiOnsic2l0ZXMiOjEwfSwiZG9tYWlucyI6WyIqIl0sImlzc3VlZEF0IjoxNzY3MjI1NjAwLCJleHBpcmVzQXQiOjI1MjQ2MDgwMDAsInZlcmlmaWNhdGlvbiI6eyJtb2RlIjoib2ZmbGluZSJ9fQ.xrJiWqCjUQuiu9DWQrmyx-f96rkz3wvbau6IN-z2EyxZwqXVtuICGpEcUN4JEpU4uDMagQpr89boeGMvGs38Cg"
|
||||
)
|
||||
|
||||
func TestVerifyUniversalPlatformToken(t *testing.T) {
|
||||
store := NewTrustStore()
|
||||
store.LicenseKeys["issuer-fixture-1"] = fixtureIssuerPublic
|
||||
|
||||
verified, err := VerifyLicense(store, fixtureOfflineToken, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if verified.Claims.Product != "ai-disclosure-standard" || verified.Claims.Edition != "pro" {
|
||||
t.Fatalf("unexpected claims: %#v", verified.Claims)
|
||||
}
|
||||
if err := ValidateLicenseContext(verified.Claims, "ai-disclosure-standard", "https://any-domain.example", ""); err != nil {
|
||||
t.Fatalf("global domain wildcard should be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownIssuerKeyIsRejected(t *testing.T) {
|
||||
_, err := VerifyLicense(NewTrustStore(), fixtureOfflineToken, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown key") {
|
||||
t.Fatalf("expected unknown-key error, got %v", err)
|
||||
}
|
||||
}
|
||||
439
third_party/license-platform-client/sdk/go/licenseclient/client.go
vendored
Normal file
439
third_party/license-platform-client/sdk/go/licenseclient/client.go
vendored
Normal file
@@ -0,0 +1,439 @@
|
||||
package licenseclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/b1tsblog/license-platform/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"`
|
||||
ServerURL string `json:"serverUrl,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 {
|
||||
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
|
||||
if cfg.ServerURL == "" {
|
||||
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/")
|
||||
}
|
||||
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
|
||||
}
|
||||
serverURL := c.resolveServerURL(ctx, claims)
|
||||
base.ServerURL = serverURL
|
||||
if serverURL == "" {
|
||||
base.Edition = "community"
|
||||
base.Reason = "online verification is required but no license server URL could be resolved"
|
||||
c.apply(base, claims)
|
||||
return
|
||||
}
|
||||
lease, source, err := c.obtainLease(ctx, claims, mode, now, serverURL)
|
||||
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, serverURL string) (licensekit.LeaseClaims, string, error) {
|
||||
leaseToken, err := c.requestLease(ctx, serverURL)
|
||||
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) resolveServerURL(ctx context.Context, claims licensekit.Claims) string {
|
||||
if value := strings.TrimRight(strings.TrimSpace(c.cfg.ServerURL), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"); value != "" {
|
||||
return value
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/license-server", nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var discovery struct {
|
||||
ServerURL string `json:"serverUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&discovery); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(strings.TrimSpace(discovery.ServerURL), "/")
|
||||
}
|
||||
|
||||
func (c *Client) requestLease(ctx context.Context, serverURL string) (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
|
||||
}
|
||||
paths := []string{"/api/v1/licenses/validate", "/v1/introspect"}
|
||||
var lastErr error
|
||||
for index, path := range paths {
|
||||
token, status, err := c.requestLeaseAt(ctx, strings.TrimRight(serverURL, "/")+path, body)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
lastErr = err
|
||||
if index == 0 && status == http.StatusNotFound {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (c *Client) requestLeaseAt(ctx context.Context, endpoint string, body []byte) (string, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", 0, 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 "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
var result introspectResponse
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return "", resp.StatusCode, fmt.Errorf("decode verification response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" {
|
||||
if result.Reason == "" {
|
||||
result.Reason = resp.Status
|
||||
}
|
||||
return "", resp.StatusCode, errors.New(result.Reason)
|
||||
}
|
||||
return result.LeaseToken, resp.StatusCode, 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
|
||||
}
|
||||
64
third_party/license-platform-client/sdk/go/licenseclient/client_test.go
vendored
Normal file
64
third_party/license-platform-client/sdk/go/licenseclient/client_test.go
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
package licenseclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/b1tsblog/license-platform/pkg/licensekit"
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureIssuerPublic = "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ"
|
||||
fixtureLeasePublic = "5_FioQvsVZr-oZXk3OhLaVaNXSywlj60RsBoXisX8vA"
|
||||
fixtureOfflineToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfb2ZmbGluZV9maXh0dXJlIiwiaXNzdWVyIjoidW5pdmVyc2FsLWxpY2Vuc2UtcGxhdGZvcm0iLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJwcm9kdWN0IjoiYWktZGlzY2xvc3VyZS1zdGFuZGFyZCIsImVkaXRpb24iOiJwcm8iLCJmZWF0dXJlcyI6WyJjdXN0b21fdGV4dCIsImN1c3RvbV9iYWRnZSJdLCJsaW1pdHMiOnsic2l0ZXMiOjEwfSwiZG9tYWlucyI6WyIqIl0sImlzc3VlZEF0IjoxNzY3MjI1NjAwLCJleHBpcmVzQXQiOjI1MjQ2MDgwMDAsInZlcmlmaWNhdGlvbiI6eyJtb2RlIjoib2ZmbGluZSJ9fQ.xrJiWqCjUQuiu9DWQrmyx-f96rkz3wvbau6IN-z2EyxZwqXVtuICGpEcUN4JEpU4uDMagQpr89boeGMvGs38Cg"
|
||||
fixtureHybridToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfcGxhdGZvcm1fZml4dHVyZSIsImlzc3VlciI6InVuaXZlcnNhbC1saWNlbnNlLXBsYXRmb3JtIiwiY3VzdG9tZXIiOiJGaXh0dXJlIEN1c3RvbWVyIiwicHJvZHVjdCI6ImFpLWRpc2Nsb3N1cmUtc3RhbmRhcmQiLCJlZGl0aW9uIjoicHJvIiwiZmVhdHVyZXMiOlsiY3VzdG9tX3RleHQiLCJjdXN0b21fYmFkZ2UiXSwibGltaXRzIjp7InNpdGVzIjoxMH0sImRvbWFpbnMiOlsiKiJdLCJpc3N1ZWRBdCI6MTc2NzIyNTYwMCwiZXhwaXJlc0F0IjoyNTI0NjA4MDAwLCJ2ZXJpZmljYXRpb24iOnsibW9kZSI6Imh5YnJpZCIsImxlYXNlVHRsU2Vjb25kcyI6MzYwMCwib2ZmbGluZUdyYWNlU2Vjb25kcyI6NzIwMCwic2VydmVyVXJsIjoiaHR0cHM6Ly9saWNlbnNlcy5leGFtcGxlLnRlc3QifX0.Jvb6NPFnZZgH0KxpEsjsVyn6kjBkk3cce1kOWrNPZmD5qA35_9Prgt1f_2RmtOtaXTp7j6ISNeYGAetpsHIQDw"
|
||||
fixtureLeaseToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxFQVNFIiwia2lkIjoibGVhc2UtZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsZWFzZUlkIjoibGVhc2VfcGxhdGZvcm1fZml4dHVyZSIsImxpY2Vuc2VJZCI6ImxpY19wbGF0Zm9ybV9maXh0dXJlIiwicHJvZHVjdCI6ImFpLWRpc2Nsb3N1cmUtc3RhbmRhcmQiLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJlZGl0aW9uIjoicHJvIiwiZmVhdHVyZXMiOlsiY3VzdG9tX3RleHQiLCJjdXN0b21fYmFkZ2UiXSwiaG9zdCI6ImV4YW1wbGUub3JnIiwiaXNzdWVkQXQiOjE3NjcyMjU2MDAsImV4cGlyZXNBdCI6MjUyNDYwODAwMH0.NwWSLoJVwY1X9WGobZu2edPAhmUhjHq8QZkCwpN2CTCqxaqWQjs1suC3mFxsjd5EG2wODr-PQh2duZ4qN4mdCA"
|
||||
)
|
||||
|
||||
func fixtureTrustStore() licensekit.TrustStore {
|
||||
store := licensekit.NewTrustStore()
|
||||
store.LicenseKeys["issuer-fixture-1"] = fixtureIssuerPublic
|
||||
store.LeaseKeys["lease-fixture-1"] = fixtureLeasePublic
|
||||
return store
|
||||
}
|
||||
|
||||
func TestOfflinePlatformLicense(t *testing.T) {
|
||||
client := New(context.Background(), Config{
|
||||
Product: "ai-disclosure-standard",
|
||||
Token: fixtureOfflineToken,
|
||||
TrustStore: fixtureTrustStore(),
|
||||
BaseURL: "https://example.org",
|
||||
Mode: licensekit.ModeOffline,
|
||||
})
|
||||
if !client.Status().Licensed || !client.Has("custom_text") {
|
||||
t.Fatalf("unexpected status: %#v", client.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridUsesPlatformValidateEndpoint(t *testing.T) {
|
||||
var requestedPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": fixtureLeaseToken})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(context.Background(), Config{
|
||||
Product: "ai-disclosure-standard",
|
||||
Token: fixtureHybridToken,
|
||||
TrustStore: fixtureTrustStore(),
|
||||
BaseURL: "https://example.org",
|
||||
Mode: licensekit.ModeHybrid,
|
||||
ServerURL: server.URL,
|
||||
})
|
||||
if !client.Status().Licensed || client.Status().Source != "online" {
|
||||
t.Fatalf("unexpected status: %#v", client.Status())
|
||||
}
|
||||
if requestedPath != "/api/v1/licenses/validate" {
|
||||
t.Fatalf("unexpected endpoint %q", requestedPath)
|
||||
}
|
||||
}
|
||||
4
third_party/license-platform-client/sdk/go/licenseclient/doc.go
vendored
Normal file
4
third_party/license-platform-client/sdk/go/licenseclient/doc.go
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
// 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
|
||||
8
web/embed.go
Normal file
8
web/embed.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// Files contains the self-contained web UI and public schemas.
|
||||
//
|
||||
//go:embed templates/*.html static/*
|
||||
var Files embed.FS
|
||||
126
web/static/app.js
Normal file
126
web/static/app.js
Normal file
@@ -0,0 +1,126 @@
|
||||
(() => {
|
||||
const cfg = window.APP_CONFIG;
|
||||
if (!cfg) return;
|
||||
const base = cfg.baseURL.replace(/\/$/, '');
|
||||
const form = document.getElementById('generator-form');
|
||||
if (!form) return;
|
||||
const $ = id => document.getElementById(id);
|
||||
const presets = {
|
||||
'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) {
|
||||
const el = $(id);
|
||||
return el && !el.disabled ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
function setArticleParams(params) {
|
||||
params.set('mode', 'article');
|
||||
const fields = {
|
||||
text: 'article-text',
|
||||
coverImage: 'article-cover-image',
|
||||
image: 'article-image',
|
||||
research: 'article-research',
|
||||
translation: 'article-translation',
|
||||
code: 'article-code'
|
||||
};
|
||||
Object.entries(fields).forEach(([name, id]) => {
|
||||
params.set(name + 'Extent', $(id).value);
|
||||
params.set(name + 'Review', $(`${id}-review`).value);
|
||||
});
|
||||
}
|
||||
|
||||
function update(applyPreset = false) {
|
||||
const mode = $('declaration-mode').value;
|
||||
const articleMode = mode === 'article';
|
||||
$('single-fields').hidden = articleMode;
|
||||
$('article-fields').hidden = !articleMode;
|
||||
|
||||
const preset = $('preset').value;
|
||||
if (!articleMode && applyPreset && presets[preset]) {
|
||||
$('extent').value = presets[preset].extent;
|
||||
$('activities').value = presets[preset].activities;
|
||||
$('review').value = presets[preset].review;
|
||||
}
|
||||
|
||||
const lang = $('lang').value;
|
||||
const locale = cfg.locales[lang] || cfg.locales.en;
|
||||
const assurance = $('assurance').value;
|
||||
const assuranceDescription = locale.text[`assurance_${assurance}_description`];
|
||||
if ($('assurance-description') && assuranceDescription) {
|
||||
$('assurance-description').textContent = assuranceDescription;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (articleMode) {
|
||||
setArticleParams(params);
|
||||
} else {
|
||||
if (preset) params.set('preset', preset);
|
||||
params.set('component', $('component').value);
|
||||
params.set('extent', $('extent').value);
|
||||
if ($('activities').value.trim()) params.set('activities', $('activities').value.trim());
|
||||
params.set('review', $('review').value);
|
||||
}
|
||||
params.set('lang', lang);
|
||||
params.set('assurance', assurance);
|
||||
if ($('subject').value.trim()) params.set('subject', $('subject').value.trim());
|
||||
|
||||
if (cfg.capabilities.custom_text) {
|
||||
if (value('custom-title')) params.set('customTitle', value('custom-title'));
|
||||
if (value('custom-description')) params.set('customDescription', value('custom-description'));
|
||||
}
|
||||
if (cfg.capabilities.custom_badge) {
|
||||
if (value('badge-label')) params.set('badgeLabel', value('badge-label'));
|
||||
if (value('badge-message')) params.set('badgeMessage', value('badge-message'));
|
||||
if (value('left-color')) params.set('leftColor', value('left-color'));
|
||||
if (value('right-color')) params.set('rightColor', value('right-color'));
|
||||
}
|
||||
|
||||
const declarationURL = `${base}/declaration?${params}`;
|
||||
const badgeParams = new URLSearchParams(params);
|
||||
badgeParams.set('theme', $('theme').value);
|
||||
badgeParams.set('link', declarationURL);
|
||||
const badgeURL = `${base}/v1/badge.svg?${badgeParams}`;
|
||||
const manifestURL = `${base}/v1/declaration.json?${params}`;
|
||||
const alt = articleMode ? ((cfg.locales[lang] || cfg.locales.en).text.badge_article || 'Article transparency') : 'AI usage disclosure';
|
||||
|
||||
$('preview-badge').src = badgeURL;
|
||||
$('preview-badge').alt = alt;
|
||||
$('preview-link').href = declarationURL;
|
||||
$('html-code').value = `<a href="${declarationURL}"><img src="${badgeURL}" alt="${alt}"></a>`;
|
||||
$('markdown-code').value = `[](${declarationURL})`;
|
||||
$('json-code').value = manifestURL;
|
||||
}
|
||||
|
||||
|
||||
document.querySelectorAll('#article-fields select[data-review]').forEach(extentSelect => {
|
||||
extentSelect.addEventListener('change', () => {
|
||||
const reviewSelect = $(extentSelect.dataset.review);
|
||||
if (!reviewSelect) return;
|
||||
if (extentSelect.value === 'none') {
|
||||
reviewSelect.value = 'none';
|
||||
} else if (reviewSelect.value === 'none') {
|
||||
reviewSelect.value = 'editorial';
|
||||
}
|
||||
update(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));
|
||||
$('copy-html')?.addEventListener('click', async () => {
|
||||
await navigator.clipboard.writeText($('html-code').value);
|
||||
const locale = cfg.locales[$('lang').value] || cfg.locales.en;
|
||||
$('copy-html').textContent = locale.text.copied || 'Copied';
|
||||
setTimeout(() => { $('copy-html').textContent = locale.text.copy_html || 'Copy HTML'; }, 1600);
|
||||
});
|
||||
$('page-language')?.addEventListener('change', event => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('lang', event.target.value);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
update(true);
|
||||
})();
|
||||
22
web/static/marketing.js
Normal file
22
web/static/marketing.js
Normal file
@@ -0,0 +1,22 @@
|
||||
(() => {
|
||||
document.getElementById('marketing-language')?.addEventListener('change', event => {
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.set('lang', event.target.value);
|
||||
window.location.assign(next.toString());
|
||||
});
|
||||
|
||||
document.querySelectorAll('.copy-code').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const target = document.getElementById(button.dataset.copyTarget);
|
||||
if (!target) return;
|
||||
const original = button.dataset.label || button.textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(target.textContent);
|
||||
button.textContent = button.dataset.copied || original;
|
||||
window.setTimeout(() => { button.textContent = original; }, 1200);
|
||||
} catch (_) {
|
||||
button.textContent = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
39
web/static/style.css
Normal file
39
web/static/style.css
Normal file
File diff suppressed because one or more lines are too long
60
web/templates/declaration.html
Normal file
60
web/templates/declaration.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{{define "declaration.html"}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Title}} · {{.Name}}</title>
|
||||
<meta name="description" content="{{.Description}}">
|
||||
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||
<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}}">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<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="{{.ManifestURL}}">JSON-LD</a></nav></header>
|
||||
<main class="declaration-page">
|
||||
<a class="back" href="/?lang={{.Lang}}">← {{index .Text "back"}}</a>
|
||||
<article class="declaration-card">
|
||||
<div class="declaration-toolbar">
|
||||
<p class="eyebrow">{{index .Text "declaration_eyebrow"}} · Schema {{.Declaration.SchemaVersion}}</p>
|
||||
<form class="declaration-language" method="get" action="/declaration">
|
||||
<label for="declaration-language">{{index .Text "field_language"}}</label>
|
||||
<select id="declaration-language" aria-label="{{index .Text "field_language"}}" onchange="window.location.assign(this.value)">
|
||||
{{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>
|
||||
</form>
|
||||
</div>
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="lead">{{.Description}}</p>
|
||||
<p class="declaration-badge"><img src="{{.BadgeURL}}" alt="{{.Title}}"></p>
|
||||
|
||||
<section class="summary-section" aria-labelledby="summary-title">
|
||||
<h2 id="summary-title">{{index .Text "article_summary_heading"}}</h2>
|
||||
<div class="summary-copy">{{range .Summary}}<p>{{.}}</p>{{end}}</div>
|
||||
</section>
|
||||
|
||||
<section class="component-section" aria-labelledby="component-title">
|
||||
<h2 id="component-title">{{index .Text "article_table_heading"}}</h2>
|
||||
<div class="component-table-wrap">
|
||||
<table class="component-table">
|
||||
<colgroup><col class="component-col"><col class="extent-col"><col class="activity-col"><col class="review-col"></colgroup>
|
||||
<thead><tr><th scope="col">{{index .Text "table_component"}}</th><th scope="col">{{index .Text "table_extent"}}</th><th scope="col">{{index .Text "table_activities"}}</th><th scope="col">{{index .Text "table_review"}}</th></tr></thead>
|
||||
<tbody>{{range .ComponentRows}}<tr><th scope="row">{{.Name}}</th><td>{{.Extent}}</td><td>{{.Activities}}</td><td>{{.Review}}</td></tr>{{if .Note}}<tr class="component-note"><td colspan="4">{{.Note}}</td></tr>{{end}}{{end}}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</article>
|
||||
</main>
|
||||
<footer><span><a href="{{.ManifestURL}}">{{index .Text "manifest"}}</a></span><span><a href="{{.ContactURL}}">{{index .Text "nav_background"}}</a></span></footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
140
web/templates/index.html
Normal file
140
web/templates/index.html
Normal file
@@ -0,0 +1,140 @@
|
||||
{{define "index.html"}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Name}}</title>
|
||||
<meta name="description" content="{{index .Text "meta_description"}}">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/?lang={{.Lang}}">{{.Name}}</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="{{.ContactURL}}">{{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}}
|
||||
</select></label>
|
||||
</header>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">{{index .Text "standard_eyebrow"}}</p>
|
||||
<h1>{{index .Text "hero_title"}}</h1>
|
||||
<p class="lead">{{index .Text "hero_lead"}}</p>
|
||||
<div class="badge-row">
|
||||
{{range .Presets}}<a href="/declaration?preset={{.Value}}&lang={{$.Lang}}"><img src="/badge/{{.Value}}.svg?theme=mono&link=auto&lang={{$.Lang}}" alt="{{.Label}}"></a>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="generator" class="panel">
|
||||
<div>
|
||||
<p class="eyebrow">{{index .Text "generator_eyebrow"}}</p>
|
||||
<h2>{{index .Text "generator_title"}}</h2>
|
||||
<p>{{index .Text "generator_intro"}}</p>
|
||||
</div>
|
||||
<form id="generator-form" class="generator-grid">
|
||||
<label class="wide">{{index .Text "field_mode"}}
|
||||
<select id="declaration-mode" name="mode"><option value="single">{{index .Text "mode_single"}}</option><option value="article">{{index .Text "mode_article"}}</option></select>
|
||||
</label>
|
||||
<div id="single-fields" class="form-subgrid wide">
|
||||
<label>{{index .Text "field_preset"}}
|
||||
<select id="preset" name="preset">
|
||||
{{range .Presets}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
|
||||
<option value="">{{index .Text "option_custom"}}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>{{index .Text "field_component"}}
|
||||
<select id="component" name="component">{{range .Components}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
|
||||
</label>
|
||||
<label>{{index .Text "field_extent"}}
|
||||
<select id="extent" name="extent">{{range .Extents}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
|
||||
</label>
|
||||
<label>{{index .Text "field_review"}}
|
||||
<select id="review" name="review">{{range .Reviews}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
|
||||
</label>
|
||||
<label class="wide">{{index .Text "field_activities"}}
|
||||
<input id="activities" name="activities" value="research" placeholder="research,summarisation">
|
||||
<small>{{index .Text "activities_help"}}</small>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset id="article-fields" class="article-builder wide" hidden>
|
||||
<legend>{{index .Text "article_fields"}}</legend>
|
||||
<p>{{index .Text "article_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 "editorial"}}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 "editorial"}}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>
|
||||
</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></select>
|
||||
</label>
|
||||
<label class="wide">{{index .Text "field_assurance"}}
|
||||
<select id="assurance" name="assurance">{{range .Assurances}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select>
|
||||
<small id="assurance-description">{{index .Text "assurance_selfDeclared_description"}}</small>
|
||||
</label>
|
||||
<label class="wide">{{index .Text "field_subject"}}
|
||||
<input id="subject" name="subject" type="url" placeholder="https://example.org/article">
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{{if or .CustomText .CustomBadge}}
|
||||
<section class="pro-panel enabled" aria-labelledby="pro-title">
|
||||
<div class="pro-heading">
|
||||
<div><p class="eyebrow">{{index .Text "pro_eyebrow"}}</p><h2 id="pro-title">{{index .Text "pro_title"}}</h2></div>
|
||||
<span class="edition-pill">{{.License.Edition}}</span>
|
||||
</div>
|
||||
<p>{{index .Text "pro_enabled"}}</p>
|
||||
<div class="generator-grid compact">
|
||||
<label>{{index .Text "custom_title"}}
|
||||
<input id="custom-title" maxlength="120" {{if not .CustomText}}disabled placeholder="{{index .Text "pro_required"}}"{{end}}>
|
||||
</label>
|
||||
<label>{{index .Text "custom_description"}}
|
||||
<input id="custom-description" maxlength="500" {{if not .CustomText}}disabled placeholder="{{index .Text "pro_required"}}"{{end}}>
|
||||
</label>
|
||||
<label>{{index .Text "custom_badge_label"}}
|
||||
<input id="badge-label" maxlength="40" {{if not .CustomBadge}}disabled placeholder="{{index .Text "pro_required"}}"{{end}}>
|
||||
</label>
|
||||
<label>{{index .Text "custom_badge_message"}}
|
||||
<input id="badge-message" maxlength="80" {{if not .CustomBadge}}disabled placeholder="{{index .Text "pro_required"}}"{{end}}>
|
||||
</label>
|
||||
<label>{{index .Text "custom_left_color"}}
|
||||
<input id="left-color" maxlength="7" pattern="#[0-9A-Fa-f]{6}" placeholder="#262626" {{if not .CustomBadge}}disabled{{end}}>
|
||||
</label>
|
||||
<label>{{index .Text "custom_right_color"}}
|
||||
<input id="right-color" maxlength="7" pattern="#[0-9A-Fa-f]{6}" placeholder="#2b6cb0" {{if not .CustomBadge}}disabled{{end}}>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<div class="preview-card">
|
||||
<p class="eyebrow">{{index .Text "preview"}}</p>
|
||||
<a id="preview-link" href="#"><img id="preview-badge" alt="{{index .Text "preview"}}"></a>
|
||||
<div class="code-tabs">
|
||||
<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>
|
||||
</div>
|
||||
<button id="copy-html" type="button">{{index .Text "copy_html"}}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="api" class="docs-grid">
|
||||
<article><p class="eyebrow">SVG</p><h2>{{index .Text "api_badge_title"}}</h2><code>{{.BaseURL}}/badge/research.svg?lang={{.Lang}}</code><p>{{index .Text "api_badge_desc"}}</p></article>
|
||||
<article><p class="eyebrow">JSON-LD</p><h2>{{index .Text "api_manifest_title"}}</h2><code>{{.BaseURL}}/v1/declaration.json?preset=research&lang={{.Lang}}</code><p>{{index .Text "api_manifest_desc"}}</p></article>
|
||||
<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.1 · Schema 1.1 · {{.License.Edition}}</span><span>{{index .Text "footer_no_legal"}}</span></footer>
|
||||
<script>window.APP_CONFIG={{.AppConfig}};</script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
126
web/templates/marketing.html
Normal file
126
web/templates/marketing.html
Normal file
@@ -0,0 +1,126 @@
|
||||
{{define "marketing.html"}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Name}} · {{.Marketing.NavFeatures}}</title>
|
||||
<meta name="description" content="{{.Marketing.MetaDescription}}">
|
||||
<link rel="canonical" href="{{.BaseURL}}/product?lang={{.Lang}}">
|
||||
<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>
|
||||
<nav>
|
||||
<a href="#features">{{.Marketing.NavFeatures}}</a>
|
||||
<a href="#install">{{.Marketing.NavInstall}}</a>
|
||||
<a href="/?lang={{.Lang}}#generator">{{.Marketing.NavGenerator}}</a>
|
||||
<a href="{{.ContactURL}}">{{.Marketing.NavBackground}}</a>
|
||||
</nav>
|
||||
<label class="language-switch"><span class="sr-only">{{.Marketing.LanguageLabel}}</span><select id="marketing-language">
|
||||
{{range .Languages}}<option value="{{.Code}}" {{if eq $.Lang .Code}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select></label>
|
||||
</header>
|
||||
|
||||
<main class="marketing-main">
|
||||
<section class="marketing-hero">
|
||||
<div class="marketing-hero-copy">
|
||||
<p class="eyebrow">{{.Marketing.HeroEyebrow}}</p>
|
||||
<h1>{{.Marketing.HeroTitle}}</h1>
|
||||
<p class="lead">{{.Marketing.HeroLead}}</p>
|
||||
<div class="cta-row">
|
||||
<a class="button primary" href="/?lang={{.Lang}}#generator">{{.Marketing.PrimaryCTA}}</a>
|
||||
</div>
|
||||
<ul class="proof-list" aria-label="Product highlights">
|
||||
{{range .Marketing.Proof}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="product-demo" aria-label="Badge examples">
|
||||
<div class="demo-window">
|
||||
<div class="demo-bar"><span></span><span></span><span></span></div>
|
||||
<div class="demo-content">
|
||||
<p class="demo-url">{{.BaseURL}}/badge/research.svg</p>
|
||||
<img src="/badge/research.svg?lang={{.Lang}}&theme=color" alt="Research badge">
|
||||
<img src="/badge/summary.svg?lang={{.Lang}}&theme=mono" alt="Summary badge">
|
||||
<img src="/badge/full.svg?lang={{.Lang}}&theme=color" alt="Full generation badge">
|
||||
<pre><code>{
|
||||
"@type": "AIUsageDeclaration",
|
||||
"schemaVersion": "1.1",
|
||||
"assurance": "selfDeclared"
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features" class="marketing-section">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">{{.Marketing.FeaturesEyebrow}}</p>
|
||||
<h2>{{.Marketing.FeaturesTitle}}</h2>
|
||||
<p>{{.Marketing.FeaturesLead}}</p>
|
||||
</div>
|
||||
<div class="feature-grid">
|
||||
{{range .Marketing.Features}}
|
||||
<article class="feature-card">
|
||||
<p class="feature-kicker">{{.Kicker}}</p>
|
||||
<h3>{{.Title}}</h3>
|
||||
<p>{{.Description}}</p>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="install" class="marketing-section install-section">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">{{.Marketing.InstallEyebrow}}</p>
|
||||
<h2>{{.Marketing.InstallTitle}}</h2>
|
||||
<p>{{.Marketing.InstallLead}}</p>
|
||||
</div>
|
||||
<div class="install-list">
|
||||
{{range $index, $method := .Marketing.InstallMethods}}
|
||||
<details class="install-method" {{if eq $index 0}}open{{end}}>
|
||||
<summary><span><strong>{{$method.Title}}</strong><small>{{$method.Summary}}</small></span><span aria-hidden="true">+</span></summary>
|
||||
<div class="code-block">
|
||||
<button type="button" class="copy-code" data-copy-target="install-{{$method.ID}}" data-label="{{$.Marketing.CopyLabel}}" data-copied="{{$.Marketing.CopiedLabel}}">{{$.Marketing.CopyLabel}}</button>
|
||||
<pre id="install-{{$method.ID}}"><code>{{$method.Code}}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="config-panel">
|
||||
<h3>{{.Marketing.ConfigTitle}}</h3>
|
||||
<div class="comparison-wrap">
|
||||
<table class="config-table">
|
||||
<thead><tr><th>{{.Marketing.ConfigVariable}}</th><th>{{.Marketing.ConfigDefault}}</th><th>{{.Marketing.ConfigMeaning}}</th></tr></thead>
|
||||
<tbody>{{range .Marketing.Config}}<tr><td><code>{{.Name}}</code></td><td><code>{{.Default}}</code></td><td>{{.Description}}</td></tr>{{end}}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="marketing-section faq-section">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">{{.Marketing.FAQEyebrow}}</p>
|
||||
<h2>{{.Marketing.FAQTitle}}</h2>
|
||||
</div>
|
||||
<div class="faq-grid">
|
||||
{{range .Marketing.FAQs}}<details><summary>{{.Question}}</summary><p>{{.Answer}}</p></details>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="final-cta">
|
||||
<h2>{{.Marketing.FinalTitle}}</h2>
|
||||
<p>{{.Marketing.FinalLead}}</p>
|
||||
<div class="cta-row">
|
||||
<a class="button primary" href="/?lang={{.Lang}}#generator">{{.Marketing.FinalPrimary}}</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><span>{{.Marketing.Footer}}</span><span>App 1.6 · Schema 1.1 · {{.License.Edition}}</span></footer>
|
||||
<script src="/static/marketing.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user