All checks were successful
release-tag / release-image (push) Successful in 1m47s
1442 lines
54 KiB
Go
1442 lines
54 KiB
Go
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 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 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 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
|
||
}
|
||
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 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.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"
|
||
}
|
||
}`
|