Files
jbergner 133fb10a93
All checks were successful
release-tag / release-image (push) Successful in 1m47s
RC-5 Versiopn: 1.6.3
2026-07-22 05:58:38 +02:00

1442 lines
54 KiB
Go
Raw Permalink 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/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"`
}
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("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(),
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)
}
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.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.LegalContext != nil && 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.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 lintelligence 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 lintelligenza 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 sie in den Metadaten ausdrücklich benannt ist.",
},
"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 it is 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 lorganisation éditrice, indépendamment du niveau dintervention de lIA 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 allorganizzazione che lo pubblica, indipendentemente dal livello dichiarato di intervento dellIA.",
},
"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
}
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) != ""
}
func assessLegalContext(d declaration.Declaration, locale i18n.Locale) legalAssessment {
categories := legalCategorySet(d)
if len(categories) == 0 {
return legalAssessment{Text: locale.Text["legal_assessment_none"], Level: "info"}
}
if categories["otherVoluntary"] {
return legalAssessment{Text: locale.Text["legal_assessment_voluntary"], Level: "info"}
}
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 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 na é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 na é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 lIA; è comunque documentata una revisione umana %s.", componentName, strings.ToLower(review))
}
return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata lIA.", 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 », lutilisation déclarée est %s, avec pour finalité %s ; aucune vérification humaine des résultats liés à lIA nest indiquée.", componentName, strings.ToLower(extent), purpose)
}
return fmt.Sprintf("Pour « %s », lutilisation déclarée est %s, avec pour finalité %s ; les résultats liés à lIA ont fait lobjet dune 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 allIA.", componentName, strings.ToLower(extent), purpose)
}
return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; i risultati legati allIA 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.2"
},
"subject": {
"type": "string",
"format": "uri"
},
"declaredAt": {
"type": "string",
"format": "date-time"
},
"language": {
"enum": [
"de",
"en",
"fr",
"es",
"it",
"nl",
"pt",
"pl"
]
},
"components": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"$ref": "#/$defs/component"
}
},
"editorialResponsibility": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
},
"assumed": {
"type": "boolean"
},
"role": {
"enum": [
"publisher",
"other"
]
}
}
},
"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"
]
}
}
}
}
},
"$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",
"assumed": "__BASE_URL__/vocab/assumed",
"role": "__BASE_URL__/vocab/responsibilityRole",
"legalContext": "__BASE_URL__/vocab/legalContext",
"categories": "__BASE_URL__/vocab/legalCategories"
}
}`