Files
ai-disclosure-standard/internal/app/server.go
T
jbergner 662afb3acc
release-tag / release-image (push) Failing after 4m50s
RC-1
2026-07-20 21:42:37 +02:00

1020 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/background"
"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
Background background.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 /background", s.handleBackground)
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) handleBackground(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/background" {
http.NotFound(w, r)
return
}
lang := s.language(r)
languageLinks, defaultLanguageURL := s.staticLanguageLinks("/background", lang)
data := pageData{
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL,
Lang: lang, Languages: i18n.Languages(), Background: background.Build(lang),
License: s.licenses.Status(), LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL,
}
s.renderHTML(w, "background.html", data)
}
func (s *Server) staticLanguageLinks(path, current string) ([]languageLink, string) {
links := make([]languageLink, 0, len(i18n.Languages()))
for _, language := range i18n.Languages() {
values := url.Values{"lang": []string{language.Code}}
relative := path + "?" + values.Encode()
links = append(links, languageLink{
Code: language.Code, Name: language.Name, URL: relative,
AbsoluteURL: s.cfg.BaseURL + relative, Current: language.Code == current,
})
}
return links, s.cfg.BaseURL + path
}
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"
}
}`