Files
ai-disclosure-standard/internal/app/server.go
T
jbergner 13efcfb986
release-tag / release-image (push) Successful in 1m50s
Update auf 1.8.0 mit Bulk-Option
2026-07-22 22:27:42 +02:00

1732 lines
64 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 (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"net/netip"
"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
validateSem chan struct{}
}
type option struct{ Value, Label string }
type fact struct{ Label, Value, Link string }
type componentRow struct{ Name, Extent, Activities, Review, Note string }
type legalAssessmentView struct{ Heading, Text, Level 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
LegalAssessment legalAssessmentView
IsArticle bool
License licenseclient.Status
CustomText bool
CustomBadge bool
Marketing marketing.Page
Background background.Page
LanguageLinks []languageLink
DefaultLanguageURL string
LegalNav legalNav
Legal legalPage
CSPNonce 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"`
BulkURL string `json:"bulkURL,omitempty"`
}
type publicLicenseStatus struct {
Edition string `json:"edition"`
Licensed bool `json:"licensed"`
Features []string `json:"features"`
Limits map[string]int64 `json:"limits,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
}
func New(ctx context.Context, cfg Config, logger *slog.Logger) (http.Handler, error) {
if err := validateConfig(cfg); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
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(), validateSem: make(chan struct{}, 32)}
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 /impressum", s.handleImprint)
s.mux.HandleFunc("GET /datenschutz", s.handlePrivacy)
s.mux.HandleFunc("GET /barrierefreiheit", s.handleAccessibility)
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("GET /v1/render", s.handleRender)
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)
if s.cfg.MetricsEnabled {
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
}
}
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(),
BulkURL: s.cfg.BulkURL,
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), LegalNav: legalNavFor(lang), 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, LegalNav: legalNavFor(lang),
}
s.renderHTML(w, "background.html", data)
}
func (s *Server) handleImprint(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/impressum" {
http.NotFound(w, r)
return
}
lang := s.language(r)
s.renderHTML(w, "legal.html", pageData{Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, CanonicalURL: s.cfg.BaseURL + "/impressum", Lang: lang, LegalNav: legalNavFor(lang), Legal: imprintPage(s.cfg, lang)})
}
func (s *Server) handlePrivacy(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/datenschutz" {
http.NotFound(w, r)
return
}
lang := s.language(r)
s.renderHTML(w, "legal.html", pageData{Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, CanonicalURL: s.cfg.BaseURL + "/datenschutz", Lang: lang, LegalNav: legalNavFor(lang), Legal: privacyPage(s.cfg, lang)})
}
func (s *Server) handleAccessibility(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/barrierefreiheit" {
http.NotFound(w, r)
return
}
lang := s.language(r)
s.renderHTML(w, "legal.html", pageData{Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, CanonicalURL: s.cfg.BaseURL + "/barrierefreiheit", Lang: lang, LegalNav: legalNavFor(lang), Legal: accessibilityPage(s.cfg, lang)})
}
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(), LegalNav: legalNavFor(lang),
}
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"
var parsedDeclaration *declaration.Declaration
if isArticle || strings.TrimSpace(q.Get("legalContext")) != "" {
if parsed, err := s.declarationFromQuery(q); err == nil {
parsedDeclaration = &parsed
if isArticle {
extent = overallExtent(parsed)
}
}
}
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
}
// When the supplied legal context indicates a likely Article 50(4) disclosure
// case, keep the first-layer wording explicit even for Pro-customised badges.
// Custom colours and the left label remain available, but an ambiguous custom
// message must not silently replace the generated disclosure wording.
if parsedDeclaration != nil {
assessment := assessLegalContext(*parsedDeclaration, locale)
if assessment.RequiresDisclosure {
message = assessment.BadgeMessage
}
}
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, Preset: presetID, Article: isArticle, 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("Cross-Origin-Resource-Policy", "cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'none'; 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), LegalAssessment: legalAssessmentPage(d, locale), IsArticle: isArticle, License: s.licenses.Status(),
LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL, LegalNav: legalNavFor(d.Language),
}
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", "*")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
_ = json.NewEncoder(w).Encode(d)
}
type renderedArtifacts struct {
Subject string `json:"subject,omitempty"`
DeclarationURL string `json:"declarationUrl"`
BadgeURL string `json:"badgeUrl"`
ManifestURL string `json:"manifestUrl"`
HTML string `json:"html"`
Markdown string `json:"markdown"`
JSONLD declaration.Declaration `json:"jsonLd"`
}
func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
q := cloneValues(r.URL.Query())
theme := strings.TrimSpace(q.Get("theme"))
q.Del("theme")
if theme == "" {
theme = "mono"
}
if theme != "mono" && theme != "color" && theme != "emoji" {
s.problem(w, http.StatusBadRequest, "invalid_theme", "theme must be one of mono, color or emoji")
return
}
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)
declarationURL := s.cfg.BaseURL + "/declaration?" + q.Encode()
manifestURL := s.cfg.BaseURL + "/v1/declaration.json?" + q.Encode()
badgeQ := cloneValues(q)
badgeQ.Set("theme", theme)
badgeQ.Set("link", declarationURL)
badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode()
assessment := assessLegalContext(d, locale)
alt := renderBadgeAlt(q, d, locale, assessment)
imageMarkup := fmt.Sprintf(`<a href="%s"><img src="%s" alt="%s"></a>`, html.EscapeString(declarationURL), html.EscapeString(badgeURL), html.EscapeString(alt))
htmlMarkup := imageMarkup
markdown := fmt.Sprintf(`[![%s](%s)](%s)`, markdownAlt(alt), badgeURL, declarationURL)
if assessment.RequiresDisclosure && theme == "emoji" {
htmlMarkup += " <span>" + html.EscapeString(assessment.BadgeMessage) + "</span>"
markdown += " **" + markdownText(assessment.BadgeMessage) + "**"
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(renderedArtifacts{
Subject: d.Subject, DeclarationURL: declarationURL, BadgeURL: badgeURL, ManifestURL: manifestURL,
HTML: htmlMarkup, Markdown: markdown, JSONLD: d,
})
}
func renderBadgeAlt(q url.Values, d declaration.Declaration, locale i18n.Locale, assessment legalAssessment) string {
if assessment.RequiresDisclosure && assessment.BadgeMessage != "" {
return assessment.BadgeMessage
}
if q.Get("mode") == "article" || len(d.Components) > 1 {
if value := locale.Text["badge_article"]; value != "" {
return value
}
}
if preset, ok := locale.Presets[q.Get("preset")]; ok && preset.Title != "" {
return preset.Title
}
for _, component := range d.Components {
if value := locale.Extents[component.AIExtent]; value != "" {
return value
}
}
return "AI usage disclosure"
}
func markdownAlt(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `]`, `\]`)
return strings.ReplaceAll(value, `[`, `\[`)
}
func markdownText(value string) string {
for _, marker := range []string{"\\", "*", "_", "`", "[", "]"} {
value = strings.ReplaceAll(value, marker, "\\"+marker)
}
return value
}
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)
if contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0])); contentType != "" && contentType != "application/json" {
s.metrics.validationFailures.Add(1)
s.problem(w, http.StatusUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json.")
return
}
select {
case s.validateSem <- struct{}{}:
defer func() { <-s.validateSem }()
default:
w.Header().Set("Retry-After", "1")
s.problem(w, http.StatusServiceUnavailable, "validation_busy", "Too many concurrent validation requests.")
return
}
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)
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
s.problem(w, http.StatusRequestEntityTooLarge, "request_too_large", "JSON request body exceeds 1 MiB.")
return
}
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) handleMetrics(w http.ResponseWriter, r *http.Request) {
if token := s.cfg.MetricsToken; token != "" {
provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if len(provided) != len(token) || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
s.metrics.serveHTTP(w, r)
}
func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
s.writeJSON(w, http.StatusOK, map[string]any{"license": publicLicense(s.licenses.Status()), "supportedLanguages": languageCodes()})
}
func publicLicense(status licenseclient.Status) publicLicenseStatus {
return publicLicenseStatus{
Edition: status.Edition, Licensed: status.Licensed, Features: append([]string(nil), status.Features...),
Limits: status.Limits, ExpiresAt: status.ExpiresAt,
}
}
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.Header().Set("Cross-Origin-Resource-Policy", "cross-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.Header().Set("Cross-Origin-Resource-Policy", "cross-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) {
nonce := randomNonce()
data.CSPNonce = nonce
var buf bytes.Buffer
if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil {
s.logger.Error("template render failed", "template", name, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
csp := "default-src 'self'; script-src 'self' 'nonce-" + nonce + "'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
if strings.HasPrefix(s.cfg.BaseURL, "https://") {
csp += "; upgrade-insecure-requests"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", csp)
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
_, _ = w.Write(buf.Bytes())
}
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 := strings.TrimSpace(r.Header.Get("X-Request-ID"))
if !validRequestID(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", "same-origin")
if s.cfg.EnableHSTS && strings.HasPrefix(s.cfg.BaseURL, "https://") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
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)
}
attrs := []any{"request_id", requestID, "method", r.Method, "path", r.URL.Path, "status", rw.status, "bytes", rw.bytes, "duration_ms", time.Since(started).Milliseconds()}
if s.cfg.LogClientIP {
attrs = append(attrs, "remote", clientIP(r, s.cfg.TrustProxy, s.cfg.TrustedProxies))
}
s.logger.Info("request", attrs...)
}()
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.Author != nil && d.Author.Name != "" {
facts = append(facts, fact{Label: locale.Text["fact_author"], Value: d.Author.Name, Link: d.Author.URL})
}
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.EditorialResponsibility.Role != "" {
role := locale.Text["responsibility_"+d.EditorialResponsibility.Role]
if role == "" {
role = d.EditorialResponsibility.Role
}
facts = append(facts, fact{Label: locale.Text["fact_responsibility_type"], Value: role})
}
}
if d.ComplaintsContact != nil && d.ComplaintsContact.Name != "" {
link := d.ComplaintsContact.URL
if link == "" && d.ComplaintsContact.Email != "" {
link = "mailto:" + d.ComplaintsContact.Email
}
facts = append(facts, fact{Label: locale.Text["fact_complaints_contact"], Value: d.ComplaintsContact.Name, Link: link})
if d.ComplaintsContact.Email != "" {
facts = append(facts, fact{Label: locale.Text["fact_complaints_email"], Value: d.ComplaintsContact.Email, Link: "mailto:" + d.ComplaintsContact.Email})
}
}
if d.LegalContext != nil {
if len(d.LegalContext.Categories) > 0 {
labels := make([]string, 0, len(d.LegalContext.Categories))
for _, category := range d.LegalContext.Categories {
if label := legalCategoryLabel(locale, category); label != "" {
labels = append(labels, label)
}
}
if len(labels) > 0 {
facts = append(facts, fact{Label: locale.Text["fact_legal_context"], Value: strings.Join(labels, "; ")})
}
}
if d.LegalContext.Role != "" {
facts = append(facts, fact{Label: locale.Text["fact_legal_role"], Value: locale.Text["legal_role_"+d.LegalContext.Role]})
}
if d.LegalContext.UseContext != "" {
facts = append(facts, fact{Label: locale.Text["fact_use_context"], Value: locale.Text["use_context_"+d.LegalContext.UseContext]})
}
if d.LegalContext.OutputDate != "" {
facts = append(facts, fact{Label: locale.Text["fact_output_date"], Value: d.LegalContext.OutputDate})
}
for _, item := range []struct {
label string
value string
}{
{locale.Text["fact_deepfake_assessment"], d.LegalContext.DeepfakeAssessment},
{locale.Text["fact_public_interest_assessment"], d.LegalContext.PublicInterestAssessment},
{locale.Text["fact_creative_work_assessment"], d.LegalContext.CreativeWorkAssessment},
} {
if item.value == "" {
continue
}
translated := locale.Text["assessment_"+item.value]
if translated == "" {
translated = item.value
}
facts = append(facts, fact{Label: item.label, Value: translated})
}
if d.LegalContext.LawEnforcementAuthorization != "" {
value := locale.Text["law_enforcement_"+d.LegalContext.LawEnforcementAuthorization]
if value == "" {
value = d.LegalContext.LawEnforcementAuthorization
}
facts = append(facts, fact{Label: locale.Text["fact_law_enforcement_authorization"], Value: value})
}
}
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.",
"Eine redaktionelle Verantwortung im Sinne von Art. 50 Abs. 4 wird nur dann als angegeben behandelt, wenn Rolle und verantwortliche Person oder Organisation in den Metadaten ausdrücklich benannt sind.",
},
"en": {
"All information is published under a single link and is also available as machine-readable JSON-LD.",
"Editorial responsibility for the purposes of Article 50(4) is treated as stated only where both the role and the responsible person or organisation are expressly named in the metadata.",
},
"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, " ")}
}
type legalAssessment struct {
RequiresDisclosure bool
BadgeMessage string
Text string
Level string
}
func legalCategorySet(d declaration.Declaration) map[string]bool {
out := map[string]bool{}
if d.LegalContext == nil {
return out
}
for _, category := range d.LegalContext.Categories {
out[category] = true
}
if d.LegalContext.DeepfakeAssessment == "yes" {
out["deepfake"] = true
}
if d.LegalContext.PublicInterestAssessment == "yes" {
out["publicInterestText"] = true
}
if d.LegalContext.CreativeWorkAssessment == "yes" {
out["artisticCreativeSatiricalFictional"] = true
}
return out
}
func legalCategoryLabel(locale i18n.Locale, category string) string {
switch category {
case "deepfake":
return locale.Text["legal_deepfake"]
case "publicInterestText":
return locale.Text["legal_public_interest"]
case "artisticCreativeSatiricalFictional":
return locale.Text["legal_creative"]
case "otherVoluntary":
return locale.Text["legal_other_voluntary"]
default:
return category
}
}
func hasSubstantiveTextReview(d declaration.Declaration) bool {
text, ok := d.Components["text"]
if !ok {
return false
}
return text.HumanReview == "editorial" || text.HumanReview == "expert"
}
func hasExplicitEditorialResponsibility(d declaration.Declaration) bool {
return d.EditorialResponsibility != nil && strings.TrimSpace(d.EditorialResponsibility.Name) != "" && strings.TrimSpace(d.EditorialResponsibility.Role) != ""
}
func assessLegalContext(d declaration.Declaration, locale i18n.Locale) legalAssessment {
categories := legalCategorySet(d)
if d.LegalContext != nil {
if d.LegalContext.UseContext == "personalNonProfessional" {
return legalAssessment{Text: locale.Text["legal_assessment_personal"], Level: "info"}
}
if d.LegalContext.OutputDate != "" {
if outputDate, err := time.Parse("2006-01-02", d.LegalContext.OutputDate); err == nil && outputDate.Before(time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC)) {
return legalAssessment{Text: locale.Text["legal_assessment_pre_application"], Level: "info"}
}
}
if d.LegalContext.Role == "provider" {
return legalAssessment{Text: locale.Text["legal_assessment_provider"], Level: "attention"}
}
}
deepfakeUnsure := d.LegalContext != nil && d.LegalContext.DeepfakeAssessment == "unsure"
publicInterestUnsure := d.LegalContext != nil && d.LegalContext.PublicInterestAssessment == "unsure"
if len(categories) == 0 && !deepfakeUnsure && !publicInterestUnsure {
return legalAssessment{Text: locale.Text["legal_assessment_none"], Level: "info"}
}
if categories["otherVoluntary"] {
return legalAssessment{Text: locale.Text["legal_assessment_voluntary"], Level: "info"}
}
if d.LegalContext != nil && d.LegalContext.LawEnforcementAuthorization == "yes" && (categories["deepfake"] || categories["publicInterestText"] || deepfakeUnsure || publicInterestUnsure) {
return legalAssessment{Text: locale.Text["legal_assessment_law_enforcement"], Level: "info"}
}
if deepfakeUnsure {
return legalAssessment{RequiresDisclosure: true, BadgeMessage: locale.Text["badge_legal_disclosure"], Text: locale.Text["legal_assessment_deepfake_unsure"], Level: "attention"}
}
if categories["deepfake"] {
text := locale.Text["legal_assessment_deepfake"]
if categories["artisticCreativeSatiricalFictional"] {
text = locale.Text["legal_assessment_deepfake_creative"]
}
return legalAssessment{RequiresDisclosure: true, BadgeMessage: locale.Text["badge_legal_disclosure"], Text: text, Level: "attention"}
}
if publicInterestUnsure {
return legalAssessment{RequiresDisclosure: true, BadgeMessage: locale.Text["badge_public_interest"], Text: locale.Text["legal_assessment_public_unsure"], Level: "attention"}
}
if categories["publicInterestText"] {
if hasSubstantiveTextReview(d) && hasExplicitEditorialResponsibility(d) {
return legalAssessment{Text: locale.Text["legal_assessment_public_exemption"], Level: "info"}
}
return legalAssessment{RequiresDisclosure: true, BadgeMessage: locale.Text["badge_public_interest"], Text: locale.Text["legal_assessment_public_required"], Level: "attention"}
}
return legalAssessment{Text: locale.Text["legal_assessment_voluntary"], Level: "info"}
}
func legalAssessmentPage(d declaration.Declaration, locale i18n.Locale) legalAssessmentView {
a := assessLegalContext(d, locale)
return legalAssessmentView{Heading: locale.Text["legal_assessment_heading"], Text: a.Text, Level: a.Level}
}
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, trusted []netip.Prefix) string {
remote := strings.TrimSpace(r.RemoteAddr)
var remoteAddr netip.Addr
if addrPort, err := netip.ParseAddrPort(remote); err == nil {
remoteAddr = addrPort.Addr()
} else {
remoteAddr, _ = netip.ParseAddr(strings.Trim(remote, "[]"))
}
if trustProxy && remoteAddr.IsValid() && prefixContains(trusted, remoteAddr) {
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
var forwarded []netip.Addr
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
addr, err := netip.ParseAddr(strings.Trim(part, "[]"))
if err != nil {
return remoteAddr.String()
}
forwarded = append(forwarded, addr.Unmap())
}
for index := len(forwarded) - 1; index >= 0; index-- {
if !prefixContains(trusted, forwarded[index]) {
return forwarded[index].String()
}
}
if len(forwarded) > 0 {
return forwarded[0].String()
}
}
if remoteAddr.IsValid() {
return remoteAddr.String()
}
return remote
}
func prefixContains(prefixes []netip.Prefix, addr netip.Addr) bool {
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func validRequestID(value string) bool {
if len(value) < 1 || len(value) > 64 {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("-_.:", r) {
continue
}
return false
}
return true
}
func randomNonce() string {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return randomID()
}
return base64.RawStdEncoding.EncodeToString(b)
}
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.3"
},
"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"
}
},
"author": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"maxLength": 200
},
"url": {
"type": "string",
"format": "uri"
}
}
},
"editorialResponsibility": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
},
"assumed": {
"type": "boolean"
},
"role": {
"enum": [
"publisher",
"other"
]
}
}
},
"complaintsContact": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string",
"maxLength": 200
},
"email": {
"type": "string",
"format": "email",
"maxLength": 320
},
"url": {
"type": "string",
"format": "uri"
}
},
"anyOf": [
{
"required": [
"email"
]
},
{
"required": [
"url"
]
}
]
},
"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}$"
}
}
},
"legalContext": {
"type": "object",
"additionalProperties": false,
"properties": {
"categories": {
"type": "array",
"uniqueItems": true,
"items": {
"enum": [
"deepfake",
"publicInterestText",
"artisticCreativeSatiricalFictional",
"otherVoluntary"
]
}
},
"actorRole": {
"enum": [
"deployer",
"provider",
"both",
"unsure"
]
},
"useContext": {
"enum": [
"professional",
"personalNonProfessional",
"unsure"
]
},
"outputDate": {
"type": "string",
"format": "date"
},
"lawEnforcementAuthorization": {
"type": "string",
"enum": [
"yes",
"no",
"unsure"
],
"description": "Self-declared assessment whether the use is authorised by law to detect, prevent, investigate or prosecute criminal offences for the Article 50(4) exception."
},
"deepfakeAssessment": {
"type": "string",
"enum": [
"yes",
"no",
"unsure"
],
"description": "Self-assessment whether the AI-involved image, audio or video qualifies as a deepfake for Article 50(4)."
},
"publicInterestAssessment": {
"type": "string",
"enum": [
"yes",
"no",
"unsure"
],
"description": "Self-assessment whether AI-involved text is published to inform the public on a matter of public interest for Article 50(4)."
},
"creativeWorkAssessment": {
"type": "string",
"enum": [
"yes",
"no",
"unsure"
],
"description": "Self-assessment whether a deepfake forms part of an evidently artistic, creative, satirical, fictional or analogous work or programme."
}
}
}
},
"$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",
"author": "https://schema.org/author",
"name": "https://schema.org/name",
"url": {"@id": "https://schema.org/url", "@type": "@id"},
"email": "https://schema.org/email",
"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",
"complaintsContact": "https://schema.org/contactPoint",
"assumed": "__BASE_URL__/vocab/assumed",
"role": "__BASE_URL__/vocab/responsibilityRole",
"legalContext": "__BASE_URL__/vocab/legalContext",
"categories": "__BASE_URL__/vocab/legalCategories",
"actorRole": "__BASE_URL__/vocab/aiActActorRole",
"useContext": "__BASE_URL__/vocab/useContext",
"outputDate": {"@id": "__BASE_URL__/vocab/outputDate", "@type": "https://www.w3.org/2001/XMLSchema#date"},
"deepfakeAssessment": "__BASE_URL__/vocab/deepfakeAssessment",
"publicInterestAssessment": "__BASE_URL__/vocab/publicInterestAssessment",
"creativeWorkAssessment": "__BASE_URL__/vocab/creativeWorkAssessment",
"lawEnforcementAuthorization": "__BASE_URL__/vocab/lawEnforcementAuthorization"
}
}`