All checks were successful
release-tag / release-image (push) Successful in 1m52s
1473 lines
56 KiB
Go
1473 lines
56 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"io/fs"
|
||
"log/slog"
|
||
"net"
|
||
"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
|
||
}
|
||
|
||
type option struct{ Value, Label string }
|
||
type fact struct{ Label, Value, Link string }
|
||
type componentRow struct{ Name, Extent, Activities, Review, Note string }
|
||
type assessmentView struct {
|
||
Code, Severity, Title, Summary string
|
||
Details []string
|
||
Warnings []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
|
||
BundleURL string
|
||
CanonicalURL string
|
||
JSONLD template.JS
|
||
AppConfig template.JS
|
||
Facts []fact
|
||
ComponentRows []componentRow
|
||
Summary []string
|
||
IsArticle bool
|
||
License licenseclient.Status
|
||
CustomText bool
|
||
CustomBadge bool
|
||
Marketing marketing.Page
|
||
Background background.Page
|
||
LanguageLinks []languageLink
|
||
DefaultLanguageURL string
|
||
Assessment assessmentView
|
||
RegulatoryFacts []fact
|
||
BulkAPI bool
|
||
BulkMaxItems int
|
||
BulkRequireAPIKey bool
|
||
ExportBundle bool
|
||
WhiteLabel bool
|
||
LegalPage legalPage
|
||
}
|
||
|
||
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 bulkClientConfig struct {
|
||
Endpoint string `json:"endpoint"`
|
||
MaxItems int `json:"maxItems"`
|
||
RequireAPIKey bool `json:"requireAPIKey"`
|
||
Texts map[string]string `json:"texts"`
|
||
}
|
||
|
||
func New(ctx context.Context, cfg Config, logger *slog.Logger) (http.Handler, error) {
|
||
if err := cfg.Validate(); err != nil {
|
||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||
}
|
||
tmpl, err := template.New("root").Funcs(template.FuncMap{"legalLabel": legalLabel}).ParseFS(webassets.Files, "templates/*.html")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse templates: %w", err)
|
||
}
|
||
trustStore, err := productTrustStore()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
licenses := licenseclient.New(ctx, licenseclient.Config{
|
||
Product: ProductID, Token: cfg.LicenseToken, TrustStore: trustStore, BaseURL: cfg.BaseURL,
|
||
InstanceID: cfg.LicenseInstanceID, Mode: cfg.LicenseMode, ServerURL: cfg.LicenseServerURL,
|
||
CacheFile: cfg.LicenseCacheFile, RefreshEvery: cfg.LicenseRefreshEvery,
|
||
RequestTimeout: cfg.LicenseTimeout, ClientVersion: ProductVersion,
|
||
})
|
||
licenses.Start(ctx)
|
||
s := &Server{cfg: cfg, logger: logger, templates: tmpl, metrics: newMetrics(), licenses: licenses, mux: http.NewServeMux()}
|
||
s.routes()
|
||
return s.middleware(s.mux), nil
|
||
}
|
||
|
||
func (s *Server) routes() {
|
||
staticFS, _ := fs.Sub(webassets.Files, "static")
|
||
fileServer := http.FileServer(http.FS(staticFS))
|
||
|
||
// Static assets are also required by the dedicated bulk workspace.
|
||
s.mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(fileServer)))
|
||
|
||
// Operational and machine-readable endpoints are available in every mode.
|
||
s.mux.HandleFunc("POST /v1/validate", s.handleValidate)
|
||
s.mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities)
|
||
s.mux.HandleFunc("GET /v1/article50-assessment.json", s.handleArticle50Assessment)
|
||
s.mux.HandleFunc("GET /schema/v1/declaration.schema.json", s.handleSchema)
|
||
s.mux.HandleFunc("GET /context/v1", s.handleContext)
|
||
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
||
s.mux.HandleFunc("GET /readyz", s.handleReady)
|
||
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
|
||
|
||
// Licensed batch processing is intentionally exposed in API and bulk modes.
|
||
s.mux.HandleFunc("POST /v1/bulk/declarations", s.handleBulkDeclarations)
|
||
|
||
if s.cfg.ServiceMode != "api" {
|
||
s.mux.HandleFunc("GET /legal", s.handleOperatorPage("legal"))
|
||
s.mux.HandleFunc("GET /imprint", s.handleOperatorPage("legal"))
|
||
s.mux.HandleFunc("GET /privacy", s.handleOperatorPage("privacy"))
|
||
s.mux.HandleFunc("GET /accessibility", s.handleOperatorPage("accessibility"))
|
||
}
|
||
|
||
if s.cfg.ServiceMode == "bulk" {
|
||
s.mux.HandleFunc("GET /", s.handleBulkWorkspace)
|
||
s.mux.HandleFunc("GET /bulk", s.handleBulkWorkspace)
|
||
return
|
||
}
|
||
|
||
s.mux.HandleFunc("GET /badge/{file}", s.handlePresetBadge)
|
||
s.mux.HandleFunc("GET /v1/badge.svg", s.handleBadge)
|
||
s.mux.HandleFunc("GET /declaration", s.handleDeclaration)
|
||
s.mux.HandleFunc("GET /v1/declaration.json", s.handleManifest)
|
||
s.mux.HandleFunc("GET /v1/declaration.bundle.json", s.handleDeclarationBundle)
|
||
|
||
if s.cfg.ServiceMode == "api" {
|
||
return
|
||
}
|
||
|
||
s.mux.HandleFunc("GET /", s.handleIndex)
|
||
s.mux.HandleFunc("GET /bulk", s.handleBulkWorkspace)
|
||
s.mux.HandleFunc("GET /product", s.handleMarketing)
|
||
s.mux.HandleFunc("GET /background", s.handleBackground)
|
||
s.mux.HandleFunc("GET /install", s.handleMarketingAlias)
|
||
}
|
||
|
||
func (s *Server) effectiveBulkMaxItems() int {
|
||
limit := s.cfg.BulkMaxItems
|
||
if licensedLimit, ok := s.licenses.Limit("bulk_items"); ok && licensedLimit > 0 && int64(limit) > licensedLimit {
|
||
limit = int(licensedLimit)
|
||
}
|
||
return limit
|
||
}
|
||
|
||
func (s *Server) handleBulkWorkspace(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/" && r.URL.Path != "/bulk" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
lang := s.language(r)
|
||
locale := i18n.Get(lang)
|
||
cfg := bulkClientConfig{
|
||
Endpoint: "/v1/bulk/declarations",
|
||
MaxItems: s.effectiveBulkMaxItems(),
|
||
RequireAPIKey: s.cfg.BulkRequireAPIKey,
|
||
Texts: map[string]string{
|
||
"error_no_items": locale.Text["bulk_error_no_items"],
|
||
"error_too_many_items": locale.Text["bulk_error_too_many_items"],
|
||
"error_api_key": locale.Text["bulk_error_api_key"],
|
||
"status_running": locale.Text["bulk_status_running"],
|
||
"status_done": locale.Text["bulk_status_done"],
|
||
"status_error": locale.Text["bulk_status_error"],
|
||
"summary_processed": locale.Text["bulk_summary_processed"],
|
||
"summary_successful": locale.Text["bulk_summary_successful"],
|
||
"summary_failed": locale.Text["bulk_summary_failed"],
|
||
"result_ok": locale.Text["bulk_result_ok"],
|
||
"result_error": locale.Text["bulk_result_error"],
|
||
"link_declaration": locale.Text["bulk_link_declaration"],
|
||
"link_manifest": locale.Text["bulk_link_manifest"],
|
||
"link_badge": locale.Text["bulk_link_badge"],
|
||
},
|
||
}
|
||
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(), Extents: orderedOptions(locale.Extents, []string{"none", "assisted", "partial", "mostly", "full"}),
|
||
Reviews: orderedOptions(locale.Reviews, []string{"none", "basic", "editorial", "expert"}),
|
||
Assurances: orderedOptions(locale.Assurances, []string{"selfDeclared", "technicallyRecorded", "signed", "verified"}),
|
||
AppConfig: template.JS(appJSON), License: s.licenses.Status(), BulkAPI: s.licenses.Has(FeatureBulkAPI),
|
||
BulkMaxItems: s.effectiveBulkMaxItems(), BulkRequireAPIKey: s.cfg.BulkRequireAPIKey,
|
||
WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel),
|
||
}
|
||
s.renderHTML(w, "bulk.html", data)
|
||
}
|
||
|
||
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),
|
||
FeatureBulkAPI: s.licenses.Has(FeatureBulkAPI),
|
||
FeatureExportBundle: s.licenses.Has(FeatureExportBundle),
|
||
},
|
||
}
|
||
appJSON, _ := json.Marshal(cfg)
|
||
data := pageData{
|
||
Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Text: locale.Text,
|
||
Languages: i18n.Languages(), Presets: presetOptions(locale), Components: orderedOptions(locale.Components, []string{"text", "coverImage", "image", "audio", "video", "code", "other"}),
|
||
Extents: orderedOptions(locale.Extents, []string{"assisted", "none", "partial", "mostly", "full"}), Reviews: orderedOptions(locale.Reviews, []string{"editorial", "expert", "basic", "none"}),
|
||
Assurances: orderedOptions(locale.Assurances, []string{"selfDeclared", "technicallyRecorded", "signed", "verified"}),
|
||
AppConfig: template.JS(appJSON), License: s.licenses.Status(), CustomText: s.licenses.Has(FeatureCustomText), CustomBadge: s.licenses.Has(FeatureCustomBadge),
|
||
BulkAPI: s.licenses.Has(FeatureBulkAPI), ExportBundle: s.licenses.Has(FeatureExportBundle),
|
||
WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel),
|
||
}
|
||
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,
|
||
WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel),
|
||
}
|
||
s.renderHTML(w, "background.html", data)
|
||
}
|
||
|
||
func (s *Server) staticLanguageLinks(path, current string) ([]languageLink, string) {
|
||
links := make([]languageLink, 0, len(i18n.Languages()))
|
||
for _, language := range i18n.Languages() {
|
||
values := url.Values{"lang": []string{language.Code}}
|
||
relative := path + "?" + values.Encode()
|
||
links = append(links, languageLink{
|
||
Code: language.Code, Name: language.Name, URL: relative,
|
||
AbsoluteURL: s.cfg.BaseURL + relative, Current: language.Code == current,
|
||
})
|
||
}
|
||
return links, s.cfg.BaseURL + path
|
||
}
|
||
|
||
func (s *Server) handleMarketing(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/product" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
lang := s.language(r)
|
||
page := marketing.Build(lang, s.cfg.PublicName, s.cfg.BaseURL, s.cfg.ContactURL)
|
||
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(),
|
||
WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel),
|
||
}
|
||
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, "licensed_feature_required", "Custom badge labels require the licensed capability custom_badge.")
|
||
return
|
||
}
|
||
if customTextRequested(q, "leftColor", "rightColor") && !s.licenses.Has(FeatureCustomBadge) {
|
||
s.problem(w, http.StatusForbidden, "licensed_feature_required", "Custom badge colours require the licensed capability custom_badge.")
|
||
return
|
||
}
|
||
leftColor, rightColor := strings.TrimSpace(q.Get("leftColor")), strings.TrimSpace(q.Get("rightColor"))
|
||
if (leftColor != "" && !validHexColor(leftColor)) || (rightColor != "" && !validHexColor(rightColor)) {
|
||
s.problem(w, http.StatusBadRequest, "invalid_colour", "Badge colours must use the form #RRGGBB.")
|
||
return
|
||
}
|
||
extent := strings.TrimSpace(q.Get("extent"))
|
||
presetID := strings.TrimSpace(q.Get("preset"))
|
||
isArticle := q.Get("mode") == "article"
|
||
if isArticle {
|
||
if d, err := s.declarationFromQuery(q); err == nil {
|
||
extent = overallExtent(d)
|
||
}
|
||
}
|
||
if p, ok := declaration.Presets[presetID]; ok && extent == "" {
|
||
extent = p.Extent
|
||
}
|
||
if extent == "" {
|
||
extent = "assisted"
|
||
}
|
||
if !declaration.IsValidExtent(extent) {
|
||
s.problem(w, http.StatusBadRequest, "invalid_extent", "extent must be one of none, assisted, partial, mostly or full")
|
||
return
|
||
}
|
||
locale := i18n.Get(q.Get("lang"))
|
||
label := locale.Text["ai_label"]
|
||
message := locale.Extents[extent]
|
||
if preset, ok := locale.Presets[presetID]; ok {
|
||
message = preset.Title
|
||
}
|
||
if isArticle {
|
||
message = locale.Text["badge_article"]
|
||
}
|
||
if d, err := s.declarationFromQuery(q); err == nil && d.RegulatoryContext != nil {
|
||
assessment := declaration.AssessArticle50(d)
|
||
if d.RegulatoryContext.Deepfake {
|
||
message = locale.Text["badge_article50_deepfake"]
|
||
} else if d.RegulatoryContext.PublicInterestText && assessment.Code == "public_interest_text_disclosure_relevant" {
|
||
message = locale.Text["badge_article50_text"]
|
||
}
|
||
if assessment.Applicable && rightColor == "" {
|
||
rightColor = "#b45309"
|
||
}
|
||
}
|
||
if isArticle && rightColor == "" {
|
||
// Article-level declarations use calm violet unless a regulatory context
|
||
// warrants an attention-oriented amber. Explicit licensed colours win.
|
||
rightColor = "#7c3aed"
|
||
}
|
||
if v := firstNonEmpty(q.Get("badgeLabel"), q.Get("label")); v != "" {
|
||
label = v
|
||
}
|
||
if v := firstNonEmpty(q.Get("badgeMessage"), q.Get("message")); v != "" {
|
||
message = v
|
||
}
|
||
link := strings.TrimSpace(q.Get("link"))
|
||
if link == "auto" {
|
||
copy := cloneValues(q)
|
||
for _, key := range []string{"link", "label", "message", "style", "theme", "leftColor", "rightColor"} {
|
||
copy.Del(key)
|
||
}
|
||
link = s.cfg.BaseURL + "/declaration?" + copy.Encode()
|
||
}
|
||
data, etag := badge.Render(badge.Options{Label: label, Message: message, Extent: extent, Style: q.Get("style"), Theme: q.Get("theme"), Link: link, LeftColor: leftColor, RightColor: rightColor})
|
||
if r.Header.Get("If-None-Match") == etag {
|
||
w.WriteHeader(http.StatusNotModified)
|
||
return
|
||
}
|
||
s.metrics.badgeRenders.Add(1)
|
||
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
|
||
w.Header().Set("ETag", etag)
|
||
if s.cfg.APIAllowedOrigin != "" {
|
||
w.Header().Set("Access-Control-Allow-Origin", s.cfg.APIAllowedOrigin)
|
||
}
|
||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||
_, _ = w.Write(data)
|
||
}
|
||
|
||
func (s *Server) handleDeclaration(w http.ResponseWriter, r *http.Request) {
|
||
q := cloneValues(r.URL.Query())
|
||
q.Set("lang", s.languageFromValues(r, q))
|
||
d, err := s.declarationFromQuery(q)
|
||
if err != nil {
|
||
s.declarationError(w, err)
|
||
return
|
||
}
|
||
locale := i18n.Get(d.Language)
|
||
presetText := locale.Presets[q.Get("preset")]
|
||
title, description := presetText.Title, presetText.Description
|
||
if title == "" {
|
||
for _, c := range d.Components {
|
||
title = locale.Extents[c.AIExtent]
|
||
break
|
||
}
|
||
description = locale.Text["transparency_text"]
|
||
}
|
||
if d.Presentation != nil {
|
||
if d.Presentation.Title != "" {
|
||
title = d.Presentation.Title
|
||
}
|
||
if d.Presentation.Description != "" {
|
||
description = d.Presentation.Description
|
||
}
|
||
}
|
||
canonicalURL, manifestURL, badgeURL := s.generatedURLs(q)
|
||
bundleURL := s.cfg.BaseURL + "/v1/declaration.bundle.json?" + q.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, BundleURL: bundleURL, CanonicalURL: canonicalURL, JSONLD: template.JS(jsonLD),
|
||
Facts: declarationFacts(d, locale), ComponentRows: declarationComponentRows(d, locale), Summary: declarationSummary(d, locale), IsArticle: isArticle, License: s.licenses.Status(),
|
||
LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL, Assessment: article50AssessmentView(declaration.AssessArticle50(d), locale),
|
||
RegulatoryFacts: regulatoryFacts(d, locale), ExportBundle: s.licenses.Has(FeatureExportBundle),
|
||
WhiteLabel: s.cfg.WhiteLabel && s.licenses.Has(FeatureWhiteLabel),
|
||
}
|
||
w.Header().Set("Link", "<"+manifestURL+">; rel=describedby; type=application/ld+json")
|
||
s.renderHTML(w, "declaration.html", data)
|
||
}
|
||
|
||
func (s *Server) declarationLanguageLinks(q url.Values, current string) ([]languageLink, string) {
|
||
links := make([]languageLink, 0, len(i18n.Languages()))
|
||
for _, language := range i18n.Languages() {
|
||
values := cloneValues(q)
|
||
values.Set("lang", language.Code)
|
||
relative := "/declaration?" + values.Encode()
|
||
links = append(links, languageLink{
|
||
Code: language.Code, Name: language.Name, URL: relative,
|
||
AbsoluteURL: s.cfg.BaseURL + relative, Current: language.Code == current,
|
||
})
|
||
}
|
||
defaultValues := cloneValues(q)
|
||
defaultValues.Del("lang")
|
||
defaultURL := s.cfg.BaseURL + "/declaration"
|
||
if encoded := defaultValues.Encode(); encoded != "" {
|
||
defaultURL += "?" + encoded
|
||
}
|
||
return links, defaultURL
|
||
}
|
||
|
||
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||
q := cloneValues(r.URL.Query())
|
||
q.Set("lang", s.languageFromValues(r, q))
|
||
d, err := s.declarationFromQuery(q)
|
||
if err != nil {
|
||
s.declarationError(w, err)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/ld+json; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
_ = json.NewEncoder(w).Encode(d)
|
||
}
|
||
|
||
func (s *Server) declarationFromQuery(q url.Values) (declaration.Declaration, error) {
|
||
return declaration.NewFromQueryWithOptions(q, s.cfg.BaseURL+"/context/v1", declaration.ParseOptions{
|
||
AllowCustomText: s.licenses.Has(FeatureCustomText), AllowCustomBadge: s.licenses.Has(FeatureCustomBadge), DefaultLanguage: s.cfg.DefaultLanguage,
|
||
})
|
||
}
|
||
|
||
func (s *Server) declarationError(w http.ResponseWriter, err error) {
|
||
if errors.Is(err, declaration.ErrCustomTextRequiresPro) || errors.Is(err, declaration.ErrCustomBadgeRequiresPro) {
|
||
s.problem(w, http.StatusForbidden, "licensed_feature_required", err.Error())
|
||
return
|
||
}
|
||
s.problem(w, http.StatusBadRequest, "invalid_declaration", err.Error())
|
||
}
|
||
|
||
func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
|
||
s.metrics.validationRequests.Add(1)
|
||
body := http.MaxBytesReader(w, r.Body, 1<<20)
|
||
defer body.Close()
|
||
dec := json.NewDecoder(body)
|
||
dec.DisallowUnknownFields()
|
||
var d declaration.Declaration
|
||
if err := dec.Decode(&d); err != nil {
|
||
s.metrics.validationFailures.Add(1)
|
||
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||
return
|
||
}
|
||
if err := ensureEOF(dec); err != nil {
|
||
s.metrics.validationFailures.Add(1)
|
||
s.problem(w, http.StatusBadRequest, "invalid_json", err.Error())
|
||
return
|
||
}
|
||
if err := declaration.Validate(d); err != nil {
|
||
s.metrics.validationFailures.Add(1)
|
||
s.problem(w, http.StatusUnprocessableEntity, "validation_failed", err.Error())
|
||
return
|
||
}
|
||
s.writeJSON(w, http.StatusOK, map[string]any{"valid": true, "schemaVersion": d.SchemaVersion})
|
||
}
|
||
|
||
func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
|
||
s.writeJSON(w, http.StatusOK, map[string]any{
|
||
"product": ProductID, "productVersion": ProductVersion, "version": ProductVersion, "schemaVersion": declaration.SchemaVersion, "serviceMode": s.cfg.ServiceMode,
|
||
"license": s.licenses.Status(), "supportedLanguages": languageCodes(),
|
||
"licensedCapabilities": map[string]bool{FeatureCustomText: s.licenses.Has(FeatureCustomText), FeatureCustomBadge: s.licenses.Has(FeatureCustomBadge), FeatureBulkAPI: s.licenses.Has(FeatureBulkAPI), FeatureExportBundle: s.licenses.Has(FeatureExportBundle), FeatureWhiteLabel: s.licenses.Has(FeatureWhiteLabel)},
|
||
})
|
||
}
|
||
|
||
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")
|
||
if s.cfg.APIAllowedOrigin != "" {
|
||
w.Header().Set("Access-Control-Allow-Origin", s.cfg.APIAllowedOrigin)
|
||
}
|
||
_, _ = 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")
|
||
if s.cfg.APIAllowedOrigin != "" {
|
||
w.Header().Set("Access-Control-Allow-Origin", s.cfg.APIAllowedOrigin)
|
||
}
|
||
_, _ = w.Write([]byte(strings.ReplaceAll(jsonLDContext, "__BASE_URL__", s.cfg.BaseURL)))
|
||
}
|
||
|
||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||
if !s.cfg.MetricsEnabled {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
expected := strings.TrimSpace(s.cfg.MetricsToken)
|
||
auth := strings.TrimSpace(r.Header.Get("Authorization"))
|
||
provided := ""
|
||
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||
provided = strings.TrimSpace(auth[len("Bearer "):])
|
||
}
|
||
if len(provided) != len(expected) || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
|
||
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
|
||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
s.metrics.serveHTTP(w, r)
|
||
}
|
||
|
||
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")
|
||
status := s.licenses.Status()
|
||
if s.cfg.ServiceMode == "bulk" && !s.licenses.Has(FeatureBulkAPI) {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = io.WriteString(w, "bulk_api capability is not available\n")
|
||
return
|
||
}
|
||
if s.cfg.ServiceMode == "bulk" && s.cfg.BulkRequireAPIKey && strings.TrimSpace(s.cfg.BulkAPIKey) == "" {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = io.WriteString(w, "bulk API key is required but not configured\n")
|
||
return
|
||
}
|
||
if s.cfg.RequireLicense && !status.Licensed {
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
_, _ = io.WriteString(w, "a valid license is required for readiness\n")
|
||
return
|
||
}
|
||
_, _ = io.WriteString(w, "ready\n")
|
||
}
|
||
|
||
func (s *Server) renderHTML(w http.ResponseWriter, name string, data pageData) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||
s.logger.Error("template render failed", "template", name, "error", err)
|
||
}
|
||
}
|
||
|
||
func (s *Server) language(r *http.Request) string {
|
||
return i18n.Resolve(r.URL.Query().Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage)
|
||
}
|
||
func (s *Server) languageFromValues(r *http.Request, values url.Values) string {
|
||
return i18n.Resolve(values.Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage)
|
||
}
|
||
|
||
func (s *Server) middleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
started := time.Now()
|
||
s.metrics.requests.Add(1)
|
||
requestID := r.Header.Get("X-Request-ID")
|
||
if requestID == "" {
|
||
requestID = randomID()
|
||
}
|
||
w.Header().Set("X-Request-ID", requestID)
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
w.Header().Set("X-Frame-Options", "DENY")
|
||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
|
||
if s.cfg.EnableHSTS {
|
||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||
}
|
||
if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/schema/") || strings.HasPrefix(r.URL.Path, "/context/") {
|
||
if s.cfg.APIAllowedOrigin != "" {
|
||
w.Header().Set("Access-Control-Allow-Origin", s.cfg.APIAllowedOrigin)
|
||
}
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Request-ID")
|
||
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}
|
||
if s.cfg.RequireLicense && !licenseExemptPath(r.URL.Path) && !s.licenses.Status().Licensed {
|
||
s.problem(rw, http.StatusServiceUnavailable, "license_required", "This deployment requires a valid runtime license.")
|
||
return
|
||
}
|
||
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)
|
||
}
|
||
args := []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 {
|
||
args = append(args, "remote", clientIP(r, s.cfg.TrustProxy, s.cfg.TrustedProxies))
|
||
}
|
||
s.logger.Info("request", args...)
|
||
}()
|
||
next.ServeHTTP(rw, r)
|
||
})
|
||
}
|
||
|
||
func licenseExemptPath(path string) bool {
|
||
switch path {
|
||
case "/healthz", "/readyz", "/metrics", "/v1/capabilities", "/legal", "/imprint", "/privacy", "/accessibility":
|
||
return true
|
||
}
|
||
return strings.HasPrefix(path, "/static/")
|
||
}
|
||
|
||
func (s *Server) bulkAuthorized(r *http.Request) bool {
|
||
if !s.cfg.BulkRequireAPIKey {
|
||
return true
|
||
}
|
||
expected := strings.TrimSpace(s.cfg.BulkAPIKey)
|
||
if expected == "" {
|
||
return false
|
||
}
|
||
provided := strings.TrimSpace(r.Header.Get("X-API-Key"))
|
||
if auth := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||
provided = strings.TrimSpace(auth[len("Bearer "):])
|
||
}
|
||
if len(provided) != len(expected) {
|
||
return false
|
||
}
|
||
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
||
}
|
||
|
||
func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) {
|
||
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"type": s.cfg.BaseURL + "/problems/" + code, "title": http.StatusText(status), "status": status, "code": code, "detail": detail})
|
||
}
|
||
|
||
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(value)
|
||
}
|
||
|
||
type responseWriter struct {
|
||
http.ResponseWriter
|
||
status, bytes int
|
||
}
|
||
|
||
func (w *responseWriter) WriteHeader(status int) {
|
||
w.status = status
|
||
w.ResponseWriter.WriteHeader(status)
|
||
}
|
||
func (w *responseWriter) Write(b []byte) (int, error) {
|
||
n, err := w.ResponseWriter.Write(b)
|
||
w.bytes += n
|
||
return n, err
|
||
}
|
||
|
||
func cacheStatic(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func presetOptions(locale i18n.Locale) []option {
|
||
ids := []string{"research", "no-ai", "summary", "full"}
|
||
out := make([]option, 0, len(ids))
|
||
for _, id := range ids {
|
||
out = append(out, option{Value: id, Label: locale.Presets[id].Title})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func orderedOptions(values map[string]string, order []string) []option {
|
||
out := make([]option, 0, len(order))
|
||
for _, key := range order {
|
||
out = append(out, option{Value: key, Label: values[key]})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func declarationFacts(d declaration.Declaration, locale i18n.Locale) []fact {
|
||
facts := []fact{{Label: locale.Text["fact_assurance"], Value: locale.Assurances[d.Assurance]}}
|
||
if d.Subject != "" {
|
||
facts = append(facts, fact{Label: locale.Text["fact_subject"], Value: d.Subject, Link: d.Subject})
|
||
}
|
||
if d.EditorialResponsibility != nil && d.EditorialResponsibility.Name != "" {
|
||
facts = append(facts, fact{Label: locale.Text["fact_responsibility"], Value: d.EditorialResponsibility.Name, Link: d.EditorialResponsibility.URL})
|
||
}
|
||
if d.DeclaredAt != "" {
|
||
facts = append(facts, fact{Label: locale.Text["fact_declared_at"], Value: d.DeclaredAt})
|
||
}
|
||
return facts
|
||
}
|
||
|
||
func regulatoryFacts(d declaration.Declaration, locale i18n.Locale) []fact {
|
||
rc := d.RegulatoryContext
|
||
if rc == nil {
|
||
return nil
|
||
}
|
||
yes, no := locale.Text["yes_value"], locale.Text["no_value"]
|
||
if yes == "" {
|
||
yes = "Yes"
|
||
}
|
||
if no == "" {
|
||
no = "No"
|
||
}
|
||
boolValue := func(v bool) string {
|
||
if v {
|
||
return yes
|
||
}
|
||
return no
|
||
}
|
||
return []fact{
|
||
{Label: locale.Text["field_public_interest_text"], Value: boolValue(rc.PublicInterestText)},
|
||
{Label: locale.Text["field_deepfake"], Value: boolValue(rc.Deepfake)},
|
||
{Label: locale.Text["field_artistic_context"], Value: boolValue(rc.ArtisticCreativeSatiricalFictional)},
|
||
{Label: locale.Text["field_substantial_review"], Value: boolValue(rc.SubstantialHumanReview)},
|
||
{Label: locale.Text["field_editorial_responsibility_confirmed"], Value: boolValue(rc.EditorialResponsibilityConfirmed)},
|
||
{Label: locale.Text["field_first_exposure"], Value: boolValue(rc.FirstExposureDisclosure)},
|
||
{Label: locale.Text["field_accessibility"], Value: boolValue(rc.AccessibilityConsidered)},
|
||
}
|
||
}
|
||
|
||
func article50AssessmentView(a declaration.Article50Assessment, locale i18n.Locale) assessmentView {
|
||
key := map[string]string{
|
||
"not_assessed": "not_assessed",
|
||
"voluntary_transparency": "voluntary",
|
||
"deepfake_disclosure_relevant": "deepfake",
|
||
"public_interest_text_disclosure_relevant": "public_text",
|
||
"public_interest_text_possible_exemption": "possible_exemption",
|
||
"multiple_article50_contexts": "multiple",
|
||
}[a.Code]
|
||
if key == "" {
|
||
key = "not_assessed"
|
||
}
|
||
details := make([]string, 0, len(a.Findings))
|
||
for _, finding := range a.Findings {
|
||
findingKey := map[string]string{
|
||
"deepfake_disclosure_relevant": "deepfake",
|
||
"public_interest_text_disclosure_relevant": "public_text",
|
||
"public_interest_text_possible_exemption": "possible_exemption",
|
||
}[finding.Code]
|
||
if text := locale.Text["assessment_"+findingKey+"_text"]; text != "" {
|
||
details = append(details, text)
|
||
}
|
||
}
|
||
warnings := make([]string, 0, len(a.Warnings))
|
||
for _, warning := range a.Warnings {
|
||
translationKey := map[string]string{
|
||
"first_exposure_not_confirmed": "warning_first_exposure",
|
||
"accessibility_not_confirmed": "warning_accessibility",
|
||
"editorial_responsibility_not_confirmed": "warning_editorial_responsibility",
|
||
"artistic_context_disclosure_manner": "warning_artistic_context",
|
||
}[warning]
|
||
if text := locale.Text[translationKey]; text != "" {
|
||
warnings = append(warnings, text)
|
||
}
|
||
}
|
||
return assessmentView{
|
||
Code: a.Code, Severity: a.Severity,
|
||
Title: locale.Text["assessment_"+key+"_title"],
|
||
Summary: locale.Text["assessment_"+key+"_text"],
|
||
Details: details,
|
||
Warnings: warnings,
|
||
}
|
||
}
|
||
|
||
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 UCNG Declaration beschreibt den angegebenen Entstehungs- und Transformationsprozess des deklarierten Inhalts. Sie dokumentiert, in welchen Bereichen KI beteiligt war, ohne Qualität oder Wahrheit des Inhalts zu bewerten.",
|
||
"en": "This UCNG Declaration describes the stated creation and transformation process of the declared content. It records where AI was involved without judging the quality or truth of the content.",
|
||
"fr": "Cette UCNG Declaration décrit le processus déclaré de création et de transformation du contenu et documente les domaines dans lesquels l’intelligence artificielle a été impliquée, sans juger la qualité ni la véracité du contenu.",
|
||
"es": "Esta UCNG Declaration describe el proceso declarado de creación y transformación del contenido y documenta dónde intervino la inteligencia artificial, sin juzgar la calidad ni la veracidad del contenido.",
|
||
"it": "Questa UCNG Declaration descrive il processo dichiarato di creazione e trasformazione del contenuto e documenta dove è intervenuta l’intelligenza artificiale, senza giudicare qualità o veridicità del contenuto.",
|
||
"nl": "Deze UCNG Declaration beschrijft het gedeclareerde creatie- en transformatieproces en legt vast waar AI betrokken was, zonder de kwaliteit of waarheid van de inhoud te beoordelen.",
|
||
"pt": "Esta UCNG Declaration descreve o processo declarado de criação e transformação do conteúdo e documenta onde a inteligência artificial esteve envolvida, sem avaliar a qualidade ou a veracidade do conteúdo.",
|
||
"pl": "Ta UCNG Declaration opisuje zadeklarowany proces tworzenia i przekształcania treści oraz dokumentuje, gdzie uczestniczyła AI, bez oceniania jakości ani prawdziwości 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": {
|
||
"Die Angaben werden unter einem gemeinsamen Link veröffentlicht und stehen zusätzlich als maschinenlesbares JSON-LD zur Verfügung.",
|
||
"Die UCNG Declaration gibt bereitgestellte Angaben wieder. Sie bedeutet nicht automatisch verified, zertifiziert, authentisch, korrekt, wahr oder rechtskonform.",
|
||
},
|
||
"en": {
|
||
"The declaration is published under a single link and is also available as machine-readable JSON-LD.",
|
||
"The UCNG Declaration reflects supplied information. It does not automatically mean verified, certified, authentic, accurate, true or legally compliant.",
|
||
},
|
||
"fr": {
|
||
"Toutes les informations sont publiées sous un lien unique et sont également disponibles au format JSON-LD lisible par machine.",
|
||
"La déclaration reprend les informations fournies par la personne ou l’organisation éditrice; elle ne constitue pas en elle-même une qualification juridique.",
|
||
},
|
||
"es": {
|
||
"Toda la información se publica mediante un único enlace y también está disponible como JSON-LD legible por máquina.",
|
||
"La declaración refleja la información facilitada por la persona u organización editora; no constituye por sí misma una calificación jurídica.",
|
||
},
|
||
"it": {
|
||
"Tutte le informazioni sono pubblicate tramite un unico collegamento e sono disponibili anche come JSON-LD leggibile automaticamente.",
|
||
"La dichiarazione riporta le informazioni fornite dalla persona o organizzazione che pubblica il contenuto; non costituisce di per sé una valutazione giuridica.",
|
||
},
|
||
"nl": {
|
||
"Alle gegevens worden via één gezamenlijke link gepubliceerd en zijn daarnaast beschikbaar als machineleesbare JSON-LD.",
|
||
"De verklaring geeft de informatie weer die door de publicerende persoon of organisatie is verstrekt; zij vormt op zichzelf geen juridische kwalificatie.",
|
||
},
|
||
"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 declaração reproduz as informações fornecidas pela pessoa ou organização responsável pela publicação; não constitui, por si só, uma qualificação jurídica.",
|
||
},
|
||
"pl": {
|
||
"Wszystkie informacje są publikowane pod jednym wspólnym odsyłaczem i są również dostępne jako maszynowo czytelny JSON-LD.",
|
||
"Deklaracja odzwierciedla informacje podane przez publikującą osobę lub organizację; sama w sobie nie stanowi kwalifikacji prawnej.",
|
||
},
|
||
}
|
||
end := closing[lang]
|
||
end = append(end, assuranceStatement(d.Assurance, locale))
|
||
return []string{strings.Join(statements, " "), strings.Join(end, " ")}
|
||
}
|
||
|
||
func componentSummaryStatement(locale i18n.Locale, name string, component declaration.Component) string {
|
||
componentName := locale.Components[name]
|
||
if componentName == "" {
|
||
componentName = name
|
||
}
|
||
extent := locale.Extents[component.AIExtent]
|
||
activities := make([]string, 0, len(component.Activities))
|
||
for _, activity := range component.Activities {
|
||
if value := locale.Activities[activity]; value != "" {
|
||
activities = append(activities, value)
|
||
} else {
|
||
activities = append(activities, activity)
|
||
}
|
||
}
|
||
activityText := strings.Join(activities, ", ")
|
||
review := locale.Reviews[component.HumanReview]
|
||
|
||
switch locale.Code {
|
||
case "de":
|
||
if component.AIExtent == "none" {
|
||
if component.HumanReview == "none" {
|
||
return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt.", componentName)
|
||
}
|
||
return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt; ergänzend ist eine %s menschliche Prüfung dokumentiert.", componentName, germanReviewAdjective(component.HumanReview))
|
||
}
|
||
purpose := ""
|
||
if activityText != "" {
|
||
purpose = " für " + activityText
|
||
}
|
||
if component.HumanReview == "none" {
|
||
return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; eine menschliche Prüfung der KI-bezogenen Ergebnisse ist nicht angegeben.", componentName, germanExtentPhrase(component.AIExtent), purpose)
|
||
}
|
||
return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; die KI-bezogenen Ergebnisse wurden %s geprüft.", componentName, germanExtentPhrase(component.AIExtent), purpose, germanReviewAdverb(component.HumanReview))
|
||
case "en":
|
||
if component.AIExtent == "none" {
|
||
if component.HumanReview == "none" {
|
||
return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName)
|
||
}
|
||
return fmt.Sprintf("According to the information provided, no AI was used for “%s”; a %s human review is nevertheless recorded.", componentName, strings.ToLower(review))
|
||
}
|
||
purpose := ""
|
||
if activityText != "" {
|
||
purpose = " for " + activityText
|
||
}
|
||
if component.HumanReview == "none" {
|
||
return fmt.Sprintf("For “%s”, %s%s is recorded; no human review of the AI-related output is specified.", componentName, strings.ToLower(extent), purpose)
|
||
}
|
||
return fmt.Sprintf("For “%s”, %s%s is recorded; the AI-related output underwent %s human review.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review))
|
||
default:
|
||
if component.AIExtent == "none" {
|
||
return genericNoAIStatement(locale.Code, componentName, component.HumanReview, review)
|
||
}
|
||
return genericAIStatement(locale.Code, componentName, extent, activityText, component.HumanReview, review)
|
||
}
|
||
}
|
||
|
||
func germanExtentPhrase(extent string) string {
|
||
switch extent {
|
||
case "assisted":
|
||
return "AI ASSISTED (unterstützende KI-Nutzung)"
|
||
case "partial":
|
||
return "MIXED (gemischte Entstehung mit teilweise KI-generierten Anteilen)"
|
||
case "mostly":
|
||
return "AI GENERATED (überwiegend KI-generierte Erstellung)"
|
||
case "full":
|
||
return "AI GENERATED (KI-generierte Erstellung)"
|
||
default:
|
||
return "eine dokumentierte KI-Rolle"
|
||
}
|
||
}
|
||
|
||
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 {
|
||
direct := remoteIP(r.RemoteAddr)
|
||
if trustProxy && direct.IsValid() && addressInPrefixes(direct, trusted) {
|
||
if x := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); x != "" {
|
||
if forwarded, err := netip.ParseAddr(x); err == nil {
|
||
return forwarded.String()
|
||
}
|
||
}
|
||
if x := strings.TrimSpace(r.Header.Get("X-Real-IP")); x != "" {
|
||
if forwarded, err := netip.ParseAddr(x); err == nil {
|
||
return forwarded.String()
|
||
}
|
||
}
|
||
}
|
||
if direct.IsValid() {
|
||
return direct.String()
|
||
}
|
||
return r.RemoteAddr
|
||
}
|
||
|
||
func remoteIP(remote string) netip.Addr {
|
||
host, _, err := net.SplitHostPort(remote)
|
||
if err != nil {
|
||
host = remote
|
||
}
|
||
addr, _ := netip.ParseAddr(strings.Trim(host, "[]"))
|
||
return addr
|
||
}
|
||
|
||
func addressInPrefixes(addr netip.Addr, prefixes []netip.Prefix) bool {
|
||
for _, prefix := range prefixes {
|
||
if prefix.Contains(addr) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
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,
|
||
"properties": {
|
||
"name": {
|
||
"type": "string"
|
||
},
|
||
"url": {
|
||
"type": "string",
|
||
"format": "uri"
|
||
}
|
||
}
|
||
},
|
||
"regulatoryContext": {
|
||
"type": "object",
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"framework": {
|
||
"enum": [
|
||
"EU-AI-Act-Article-50"
|
||
]
|
||
},
|
||
"publicInterestText": {
|
||
"type": "boolean"
|
||
},
|
||
"deepfake": {
|
||
"type": "boolean"
|
||
},
|
||
"artisticCreativeSatiricalFictional": {
|
||
"type": "boolean"
|
||
},
|
||
"substantialHumanReview": {
|
||
"type": "boolean"
|
||
},
|
||
"editorialResponsibilityConfirmed": {
|
||
"type": "boolean"
|
||
},
|
||
"firstExposureDisclosure": {
|
||
"type": "boolean"
|
||
},
|
||
"accessibilityConsidered": {
|
||
"type": "boolean"
|
||
}
|
||
}
|
||
},
|
||
"assurance": {
|
||
"enum": [
|
||
"selfDeclared",
|
||
"technicallyRecorded",
|
||
"signed",
|
||
"verified"
|
||
]
|
||
},
|
||
"presentation": {
|
||
"type": "object",
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"title": {
|
||
"type": "string",
|
||
"maxLength": 120
|
||
},
|
||
"description": {
|
||
"type": "string",
|
||
"maxLength": 500
|
||
},
|
||
"badgeLabel": {
|
||
"type": "string",
|
||
"maxLength": 40
|
||
},
|
||
"badgeMessage": {
|
||
"type": "string",
|
||
"maxLength": 80
|
||
},
|
||
"leftColor": {
|
||
"type": "string",
|
||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||
},
|
||
"rightColor": {
|
||
"type": "string",
|
||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"$defs": {
|
||
"component": {
|
||
"type": "object",
|
||
"additionalProperties": false,
|
||
"required": [
|
||
"aiExtent",
|
||
"humanReview"
|
||
],
|
||
"properties": {
|
||
"aiExtent": {
|
||
"enum": [
|
||
"none",
|
||
"assisted",
|
||
"partial",
|
||
"mostly",
|
||
"full"
|
||
]
|
||
},
|
||
"activities": {
|
||
"type": "array",
|
||
"uniqueItems": true,
|
||
"items": {
|
||
"enum": [
|
||
"research",
|
||
"summarisation",
|
||
"drafting",
|
||
"generation",
|
||
"translation",
|
||
"editing",
|
||
"imageGeneration",
|
||
"codeGeneration",
|
||
"transcription",
|
||
"classification"
|
||
]
|
||
}
|
||
},
|
||
"humanReview": {
|
||
"enum": [
|
||
"none",
|
||
"basic",
|
||
"editorial",
|
||
"expert"
|
||
]
|
||
},
|
||
"note": {
|
||
"type": "string",
|
||
"maxLength": 500
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}`
|
||
|
||
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",
|
||
"regulatoryContext": "__BASE_URL__/vocab/regulatoryContext",
|
||
"framework": "__BASE_URL__/vocab/framework",
|
||
"publicInterestText": "__BASE_URL__/vocab/publicInterestText",
|
||
"deepfake": "__BASE_URL__/vocab/deepfake",
|
||
"artisticCreativeSatiricalFictional": "__BASE_URL__/vocab/artisticCreativeSatiricalFictional",
|
||
"substantialHumanReview": "__BASE_URL__/vocab/substantialHumanReview",
|
||
"editorialResponsibilityConfirmed": "__BASE_URL__/vocab/editorialResponsibilityConfirmed",
|
||
"firstExposureDisclosure": "__BASE_URL__/vocab/firstExposureDisclosure",
|
||
"accessibilityConsidered": "__BASE_URL__/vocab/accessibilityConsidered"
|
||
}
|
||
}`
|