package app import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "html/template" "io" "io/fs" "log/slog" "net/http" "net/url" "runtime/debug" "sort" "strings" "time" "github.com/b1tsblog/ai-disclosure-standard/internal/background" "github.com/b1tsblog/ai-disclosure-standard/internal/badge" "github.com/b1tsblog/ai-disclosure-standard/internal/declaration" "github.com/b1tsblog/ai-disclosure-standard/internal/i18n" "github.com/b1tsblog/ai-disclosure-standard/internal/marketing" webassets "github.com/b1tsblog/ai-disclosure-standard/web" "github.com/b1tsblog/license-platform/sdk/go/licenseclient" ) type Server struct { cfg Config logger *slog.Logger templates *template.Template metrics *metrics licenses *licenseclient.Client mux *http.ServeMux } type option struct{ Value, Label string } type fact struct{ Label, Value, Link string } type componentRow struct{ Name, Extent, Activities, Review, Note string } type languageLink struct { Code, Name, URL, AbsoluteURL string Current bool } type pageData struct { Name string BaseURL string ContactURL string Lang string Text map[string]string Languages []i18n.LanguageOption Presets []option Components []option Extents []option Reviews []option Assurances []option Declaration declaration.Declaration Title string Description string BadgeURL string ManifestURL string CanonicalURL string JSONLD template.JS AppConfig template.JS Facts []fact ComponentRows []componentRow Summary []string IsArticle bool License licenseclient.Status CustomText bool CustomBadge bool Marketing marketing.Page Background background.Page LanguageLinks []languageLink DefaultLanguageURL string } type clientConfig struct { BaseURL string `json:"baseURL"` SelectedLanguage string `json:"selectedLanguage"` Locales map[string]i18n.Locale `json:"locales"` Capabilities map[string]bool `json:"capabilities"` } func New(ctx context.Context, cfg Config, logger *slog.Logger) (http.Handler, error) { tmpl, err := template.New("root").ParseFS(webassets.Files, "templates/*.html") if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } trustStore, err := productTrustStore() if err != nil { return nil, err } licenses := licenseclient.New(ctx, licenseclient.Config{ Product: ProductID, Token: cfg.LicenseToken, TrustStore: trustStore, BaseURL: cfg.BaseURL, InstanceID: cfg.LicenseInstanceID, Mode: cfg.LicenseMode, ServerURL: cfg.LicenseServerURL, CacheFile: cfg.LicenseCacheFile, RefreshEvery: cfg.LicenseRefreshEvery, RequestTimeout: cfg.LicenseTimeout, ClientVersion: ProductVersion, }) licenses.Start(ctx) s := &Server{cfg: cfg, logger: logger, templates: tmpl, metrics: newMetrics(), licenses: licenses, mux: http.NewServeMux()} s.routes() return s.middleware(s.mux), nil } func (s *Server) routes() { staticFS, _ := fs.Sub(webassets.Files, "static") fileServer := http.FileServer(http.FS(staticFS)) s.mux.HandleFunc("GET /", s.handleIndex) s.mux.HandleFunc("GET /product", s.handleMarketing) s.mux.HandleFunc("GET /background", s.handleBackground) s.mux.HandleFunc("GET /install", s.handleMarketingAlias) s.mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(fileServer))) s.mux.HandleFunc("GET /badge/{file}", s.handlePresetBadge) s.mux.HandleFunc("GET /v1/badge.svg", s.handleBadge) s.mux.HandleFunc("GET /declaration", s.handleDeclaration) s.mux.HandleFunc("GET /v1/declaration.json", s.handleManifest) s.mux.HandleFunc("POST /v1/validate", s.handleValidate) s.mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities) s.mux.HandleFunc("GET /schema/v1/declaration.schema.json", s.handleSchema) s.mux.HandleFunc("GET /context/v1", s.handleContext) s.mux.HandleFunc("GET /healthz", s.handleHealth) s.mux.HandleFunc("GET /readyz", s.handleReady) s.mux.HandleFunc("GET /metrics", s.metrics.serveHTTP) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } lang := s.language(r) locale := i18n.Get(lang) cfg := clientConfig{ BaseURL: s.cfg.BaseURL, SelectedLanguage: lang, Locales: i18n.ClientCatalogs(), Capabilities: map[string]bool{ FeatureCustomText: s.licenses.Has(FeatureCustomText), FeatureCustomBadge: s.licenses.Has(FeatureCustomBadge), }, } appJSON, _ := json.Marshal(cfg) data := pageData{ Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Text: locale.Text, Languages: i18n.Languages(), Presets: presetOptions(locale), Components: orderedOptions(locale.Components, []string{"text", "coverImage", "image", "audio", "video", "code", "other"}), Extents: orderedOptions(locale.Extents, []string{"assisted", "none", "partial", "mostly", "full"}), Reviews: orderedOptions(locale.Reviews, []string{"editorial", "expert", "basic", "none"}), Assurances: orderedOptions(locale.Assurances, []string{"selfDeclared", "technicallyRecorded", "signed", "verified"}), AppConfig: template.JS(appJSON), License: s.licenses.Status(), CustomText: s.licenses.Has(FeatureCustomText), CustomBadge: s.licenses.Has(FeatureCustomBadge), } s.renderHTML(w, "index.html", data) } func (s *Server) handleBackground(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/background" { http.NotFound(w, r) return } lang := s.language(r) languageLinks, defaultLanguageURL := s.staticLanguageLinks("/background", lang) data := pageData{ Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Languages: i18n.Languages(), Background: background.Build(lang), License: s.licenses.Status(), LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL, } s.renderHTML(w, "background.html", data) } func (s *Server) staticLanguageLinks(path, current string) ([]languageLink, string) { links := make([]languageLink, 0, len(i18n.Languages())) for _, language := range i18n.Languages() { values := url.Values{"lang": []string{language.Code}} relative := path + "?" + values.Encode() links = append(links, languageLink{ Code: language.Code, Name: language.Name, URL: relative, AbsoluteURL: s.cfg.BaseURL + relative, Current: language.Code == current, }) } return links, s.cfg.BaseURL + path } func (s *Server) handleMarketing(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/product" { http.NotFound(w, r) return } lang := s.language(r) page := marketing.Build(lang, s.cfg.PublicName, s.cfg.BaseURL, s.cfg.SalesURL, s.cfg.ContactURL, marketing.Prices{ Community: s.cfg.CommunityPrice, Pro: s.cfg.ProPrice, Publisher: s.cfg.PublisherPrice, Agency: s.cfg.AgencyPrice, }) data := pageData{ Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: lang, Languages: i18n.Languages(), Marketing: page, License: s.licenses.Status(), } s.renderHTML(w, "marketing.html", data) } func (s *Server) handleMarketingAlias(w http.ResponseWriter, r *http.Request) { target := "/product" if raw := r.URL.Query().Encode(); raw != "" { target += "?" + raw } if r.URL.Path == "/install" { target += "#install" } http.Redirect(w, r, target, http.StatusTemporaryRedirect) } func (s *Server) handlePresetBadge(w http.ResponseWriter, r *http.Request) { file := r.PathValue("file") if !strings.HasSuffix(file, ".svg") { http.NotFound(w, r) return } presetID := strings.TrimSuffix(file, ".svg") if _, ok := declaration.Presets[presetID]; !ok { s.problem(w, http.StatusNotFound, "unknown_preset", "Unknown badge preset.") return } q := cloneValues(r.URL.Query()) q.Set("preset", presetID) q.Set("lang", s.languageFromValues(r, q)) s.renderBadge(w, r, q) } func (s *Server) handleBadge(w http.ResponseWriter, r *http.Request) { q := cloneValues(r.URL.Query()) q.Set("lang", s.languageFromValues(r, q)) s.renderBadge(w, r, q) } func (s *Server) renderBadge(w http.ResponseWriter, r *http.Request, q url.Values) { if customTextRequested(q, "label", "badgeLabel", "message", "badgeMessage") && !s.licenses.Has(FeatureCustomBadge) { s.problem(w, http.StatusForbidden, "pro_feature_required", "Custom badge labels require the Pro feature custom_badge.") return } if customTextRequested(q, "leftColor", "rightColor") && !s.licenses.Has(FeatureCustomBadge) { s.problem(w, http.StatusForbidden, "pro_feature_required", "Custom badge colours require the Pro feature custom_badge.") return } leftColor, rightColor := strings.TrimSpace(q.Get("leftColor")), strings.TrimSpace(q.Get("rightColor")) if (leftColor != "" && !validHexColor(leftColor)) || (rightColor != "" && !validHexColor(rightColor)) { s.problem(w, http.StatusBadRequest, "invalid_colour", "Badge colours must use the form #RRGGBB.") return } extent := strings.TrimSpace(q.Get("extent")) presetID := strings.TrimSpace(q.Get("preset")) isArticle := q.Get("mode") == "article" if isArticle { if d, err := s.declarationFromQuery(q); err == nil { extent = overallExtent(d) } } if p, ok := declaration.Presets[presetID]; ok && extent == "" { extent = p.Extent } if extent == "" { extent = "assisted" } if !declaration.IsValidExtent(extent) { s.problem(w, http.StatusBadRequest, "invalid_extent", "extent must be one of none, assisted, partial, mostly or full") return } locale := i18n.Get(q.Get("lang")) label := locale.Text["ai_label"] message := locale.Extents[extent] if preset, ok := locale.Presets[presetID]; ok { message = preset.Title } if isArticle { message = locale.Text["badge_article"] // Article-level declarations combine several AI-use states. Use a calm // violet as the neutral default instead of inheriting the strongest // component colour (which may be red). Explicit Pro colours still win. if rightColor == "" { rightColor = "#7c3aed" } } if v := firstNonEmpty(q.Get("badgeLabel"), q.Get("label")); v != "" { label = v } if v := firstNonEmpty(q.Get("badgeMessage"), q.Get("message")); v != "" { message = v } link := strings.TrimSpace(q.Get("link")) if link == "auto" { copy := cloneValues(q) for _, key := range []string{"link", "label", "message", "style", "theme", "leftColor", "rightColor"} { copy.Del(key) } link = s.cfg.BaseURL + "/declaration?" + copy.Encode() } data, etag := badge.Render(badge.Options{Label: label, Message: message, Extent: extent, Style: q.Get("style"), Theme: q.Get("theme"), Link: link, LeftColor: leftColor, RightColor: rightColor}) if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return } s.metrics.badgeRenders.Add(1) w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400") w.Header().Set("ETag", etag) w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") _, _ = w.Write(data) } func (s *Server) handleDeclaration(w http.ResponseWriter, r *http.Request) { q := cloneValues(r.URL.Query()) q.Set("lang", s.languageFromValues(r, q)) d, err := s.declarationFromQuery(q) if err != nil { s.declarationError(w, err) return } locale := i18n.Get(d.Language) presetText := locale.Presets[q.Get("preset")] title, description := presetText.Title, presetText.Description if title == "" { for _, c := range d.Components { title = locale.Extents[c.AIExtent] break } description = locale.Text["transparency_text"] } if d.Presentation != nil { if d.Presentation.Title != "" { title = d.Presentation.Title } if d.Presentation.Description != "" { description = d.Presentation.Description } } canonicalURL := s.cfg.BaseURL + "/declaration?" + q.Encode() manifestURL := s.cfg.BaseURL + "/v1/declaration.json?" + q.Encode() badgeQ := cloneValues(q) badgeQ.Set("link", canonicalURL) badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode() jsonLD, _ := json.Marshal(d) isArticle := q.Get("mode") == "article" || len(d.Components) > 1 if isArticle && (d.Presentation == nil || d.Presentation.Title == "") { title = locale.Text["article_title"] description = articleShortDescription(d, locale) } languageLinks, defaultLanguageURL := s.declarationLanguageLinks(q, d.Language) data := pageData{ Name: s.cfg.PublicName, BaseURL: s.cfg.BaseURL, ContactURL: s.cfg.ContactURL, Lang: d.Language, Text: locale.Text, Languages: i18n.Languages(), Declaration: d, Title: title, Description: description, BadgeURL: badgeURL, ManifestURL: manifestURL, CanonicalURL: canonicalURL, JSONLD: template.JS(jsonLD), Facts: declarationFacts(d, locale), ComponentRows: declarationComponentRows(d, locale), Summary: declarationSummary(d, locale), IsArticle: isArticle, License: s.licenses.Status(), LanguageLinks: languageLinks, DefaultLanguageURL: defaultLanguageURL, } w.Header().Set("Link", "<"+manifestURL+">; rel=describedby; type=application/ld+json") s.renderHTML(w, "declaration.html", data) } func (s *Server) declarationLanguageLinks(q url.Values, current string) ([]languageLink, string) { links := make([]languageLink, 0, len(i18n.Languages())) for _, language := range i18n.Languages() { values := cloneValues(q) values.Set("lang", language.Code) relative := "/declaration?" + values.Encode() links = append(links, languageLink{ Code: language.Code, Name: language.Name, URL: relative, AbsoluteURL: s.cfg.BaseURL + relative, Current: language.Code == current, }) } defaultValues := cloneValues(q) defaultValues.Del("lang") defaultURL := s.cfg.BaseURL + "/declaration" if encoded := defaultValues.Encode(); encoded != "" { defaultURL += "?" + encoded } return links, defaultURL } func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { q := cloneValues(r.URL.Query()) q.Set("lang", s.languageFromValues(r, q)) d, err := s.declarationFromQuery(q) if err != nil { s.declarationError(w, err) return } w.Header().Set("Content-Type", "application/ld+json; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400") w.Header().Set("Access-Control-Allow-Origin", "*") _ = json.NewEncoder(w).Encode(d) } func (s *Server) declarationFromQuery(q url.Values) (declaration.Declaration, error) { return declaration.NewFromQueryWithOptions(q, s.cfg.BaseURL+"/context/v1", declaration.ParseOptions{ AllowCustomText: s.licenses.Has(FeatureCustomText), AllowCustomBadge: s.licenses.Has(FeatureCustomBadge), DefaultLanguage: s.cfg.DefaultLanguage, }) } func (s *Server) declarationError(w http.ResponseWriter, err error) { if errors.Is(err, declaration.ErrCustomTextRequiresPro) || errors.Is(err, declaration.ErrCustomBadgeRequiresPro) { s.problem(w, http.StatusForbidden, "pro_feature_required", err.Error()) return } s.problem(w, http.StatusBadRequest, "invalid_declaration", err.Error()) } func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) { s.metrics.validationRequests.Add(1) body := http.MaxBytesReader(w, r.Body, 1<<20) defer body.Close() dec := json.NewDecoder(body) dec.DisallowUnknownFields() var d declaration.Declaration if err := dec.Decode(&d); err != nil { s.metrics.validationFailures.Add(1) s.problem(w, http.StatusBadRequest, "invalid_json", err.Error()) return } if err := ensureEOF(dec); err != nil { s.metrics.validationFailures.Add(1) s.problem(w, http.StatusBadRequest, "invalid_json", err.Error()) return } if err := declaration.Validate(d); err != nil { s.metrics.validationFailures.Add(1) s.problem(w, http.StatusUnprocessableEntity, "validation_failed", err.Error()) return } s.writeJSON(w, http.StatusOK, map[string]any{"valid": true, "schemaVersion": d.SchemaVersion}) } func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) { s.writeJSON(w, http.StatusOK, map[string]any{"license": s.licenses.Status(), "supportedLanguages": languageCodes()}) } func (s *Server) handleSchema(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/schema+json; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") w.Header().Set("Access-Control-Allow-Origin", "*") _, _ = w.Write([]byte(strings.ReplaceAll(declarationSchema, "__BASE_URL__", s.cfg.BaseURL))) } func (s *Server) handleContext(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/ld+json; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") w.Header().Set("Access-Control-Allow-Origin", "*") _, _ = w.Write([]byte(strings.ReplaceAll(jsonLDContext, "__BASE_URL__", s.cfg.BaseURL))) } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = io.WriteString(w, "ok\n") } func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = io.WriteString(w, "ready\n") } func (s *Server) renderHTML(w http.ResponseWriter, name string, data pageData) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := s.templates.ExecuteTemplate(w, name, data); err != nil { s.logger.Error("template render failed", "template", name, "error", err) } } func (s *Server) language(r *http.Request) string { return i18n.Resolve(r.URL.Query().Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage) } func (s *Server) languageFromValues(r *http.Request, values url.Values) string { return i18n.Resolve(values.Get("lang"), r.Header.Get("Accept-Language"), s.cfg.DefaultLanguage) } func (s *Server) middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started := time.Now() s.metrics.requests.Add(1) requestID := r.Header.Get("X-Request-ID") if requestID == "" { requestID = randomID() } w.Header().Set("X-Request-ID", requestID) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin") if strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/schema/") || strings.HasPrefix(r.URL.Path, "/context/") { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } } rw := &responseWriter{ResponseWriter: w, status: http.StatusOK} defer func() { if recovered := recover(); recovered != nil { s.metrics.panics.Add(1) s.logger.Error("handler panic", "request_id", requestID, "panic", recovered, "stack", string(debug.Stack())) http.Error(rw, "internal server error", http.StatusInternalServerError) } s.logger.Info("request", "request_id", requestID, "method", r.Method, "path", r.URL.Path, "status", rw.status, "bytes", rw.bytes, "duration_ms", time.Since(started).Milliseconds(), "remote", clientIP(r, s.cfg.TrustProxy)) }() next.ServeHTTP(rw, r) }) } func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) { w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{"type": s.cfg.BaseURL + "/problems/" + code, "title": http.StatusText(status), "status": status, "code": code, "detail": detail}) } func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } type responseWriter struct { http.ResponseWriter status, bytes int } func (w *responseWriter) WriteHeader(status int) { w.status = status w.ResponseWriter.WriteHeader(status) } func (w *responseWriter) Write(b []byte) (int, error) { n, err := w.ResponseWriter.Write(b) w.bytes += n return n, err } func cacheStatic(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=3600") next.ServeHTTP(w, r) }) } func presetOptions(locale i18n.Locale) []option { ids := []string{"research", "no-ai", "summary", "full"} out := make([]option, 0, len(ids)) for _, id := range ids { out = append(out, option{Value: id, Label: locale.Presets[id].Title}) } return out } func orderedOptions(values map[string]string, order []string) []option { out := make([]option, 0, len(order)) for _, key := range order { out = append(out, option{Value: key, Label: values[key]}) } return out } func declarationFacts(d declaration.Declaration, locale i18n.Locale) []fact { facts := []fact{{Label: locale.Text["fact_assurance"], Value: locale.Assurances[d.Assurance]}} if d.Subject != "" { facts = append(facts, fact{Label: locale.Text["fact_subject"], Value: d.Subject, Link: d.Subject}) } if d.EditorialResponsibility != nil && d.EditorialResponsibility.Name != "" { facts = append(facts, fact{Label: locale.Text["fact_responsibility"], Value: d.EditorialResponsibility.Name, Link: d.EditorialResponsibility.URL}) } if d.DeclaredAt != "" { facts = append(facts, fact{Label: locale.Text["fact_declared_at"], Value: d.DeclaredAt}) } return facts } func declarationComponentRows(d declaration.Declaration, locale i18n.Locale) []componentRow { order := []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code", "other"} rows := make([]componentRow, 0, len(d.Components)) for _, name := range order { component, ok := d.Components[name] if !ok { continue } activities := make([]string, 0, len(component.Activities)) for _, activity := range component.Activities { if translated := locale.Activities[activity]; translated != "" { activities = append(activities, translated) } else { activities = append(activities, activity) } } activityText := locale.Text["not_specified"] if component.AIExtent == "none" { activityText = locale.Text["not_applicable"] } if activityText == "" { activityText = locale.Text["none_value"] } if len(activities) > 0 { activityText = strings.Join(activities, ", ") } rows = append(rows, componentRow{Name: locale.Components[name], Extent: locale.Extents[component.AIExtent], Activities: activityText, Review: locale.Reviews[component.HumanReview], Note: component.Note}) } return rows } func overallExtent(d declaration.Declaration) string { rank := map[string]int{"none": 0, "assisted": 1, "partial": 2, "mostly": 3, "full": 4} best, bestRank := "none", 0 for _, c := range d.Components { if rank[c.AIExtent] > bestRank { best, bestRank = c.AIExtent, rank[c.AIExtent] } } return best } func articleShortDescription(d declaration.Declaration, locale i18n.Locale) string { rows := declarationComponentRows(d, locale) parts := make([]string, 0, len(rows)) for _, row := range rows { parts = append(parts, row.Name+": "+row.Extent) } return strings.Join(parts, " · ") } func declarationSummary(d declaration.Declaration, locale i18n.Locale) []string { if len(d.Components) == 0 { return nil } intro := map[string]string{ "de": "Diese Erklärung dokumentiert nachvollziehbar, in welchen Bereichen künstliche Intelligenz bei der Erstellung und Veröffentlichung des gekennzeichneten Inhalts eingesetzt wurde.", "en": "This declaration documents, in a transparent and traceable form, where artificial intelligence was used in creating and publishing the labelled content.", "fr": "Cette déclaration documente de manière transparente et traçable les domaines dans lesquels l’intelligence artificielle a été utilisée pour créer et publier le contenu identifié.", "es": "Esta declaración documenta de forma transparente y trazable en qué áreas se utilizó inteligencia artificial para crear y publicar el contenido identificado.", "it": "La presente dichiarazione documenta in modo trasparente e tracciabile gli ambiti in cui l’intelligenza artificiale è stata utilizzata per creare e pubblicare il contenuto indicato.", "nl": "Deze verklaring documenteert op transparante en navolgbare wijze op welke onderdelen kunstmatige intelligentie is gebruikt bij het maken en publiceren van de aangeduide inhoud.", "pt": "Esta declaração documenta, de forma transparente e rastreável, as áreas em que a inteligência artificial foi utilizada na criação e publicação do conteúdo identificado.", "pl": "Niniejsza deklaracja w przejrzysty i możliwy do prześledzenia sposób dokumentuje obszary, w których wykorzystano sztuczną inteligencję podczas tworzenia i publikacji oznaczonej treści.", } lang := locale.Code if intro[lang] == "" { lang = "en" } statements := []string{intro[lang]} for _, name := range []string{"text", "coverImage", "image", "research", "translation", "audio", "video", "code", "other"} { component, ok := d.Components[name] if !ok { continue } statements = append(statements, componentSummaryStatement(locale, name, component)) } closing := map[string][]string{ "de": { "Sämtliche Angaben werden unter einem gemeinsamen Link veröffentlicht und stehen zusätzlich als maschinenlesbares JSON-LD zur Verfügung.", "Die inhaltliche und redaktionelle Verantwortung verbleibt unabhängig vom ausgewiesenen KI-Anteil bei der veröffentlichenden Person oder Organisation.", }, "en": { "All information is published under a single link and is also available as machine-readable JSON-LD.", "Responsibility for the content and its editorial publication remains with the publishing person or organisation, irrespective of the stated level of AI involvement.", }, "fr": { "Toutes les informations sont publiées sous un lien unique et sont également disponibles au format JSON-LD lisible par machine.", "La responsabilité du contenu et de sa publication éditoriale demeure celle de la personne ou de l’organisation éditrice, indépendamment du niveau d’intervention de l’IA indiqué.", }, "es": { "Toda la información se publica mediante un único enlace y también está disponible como JSON-LD legible por máquina.", "La responsabilidad sobre el contenido y su publicación editorial permanece en la persona u organización editora, con independencia del nivel de intervención de la IA indicado.", }, "it": { "Tutte le informazioni sono pubblicate tramite un unico collegamento e sono disponibili anche come JSON-LD leggibile automaticamente.", "La responsabilità del contenuto e della sua pubblicazione editoriale resta in capo alla persona o all’organizzazione che lo pubblica, indipendentemente dal livello dichiarato di intervento dell’IA.", }, "nl": { "Alle gegevens worden via één gezamenlijke link gepubliceerd en zijn daarnaast beschikbaar als machineleesbare JSON-LD.", "De inhoudelijke en redactionele verantwoordelijkheid blijft, ongeacht de vermelde mate van AI-gebruik, bij de publicerende persoon of organisatie.", }, "pt": { "Todas as informações são publicadas através de uma única ligação e estão igualmente disponíveis como JSON-LD legível por máquina.", "A responsabilidade pelo conteúdo e pela sua publicação editorial permanece com a pessoa ou organização responsável pela publicação, independentemente do grau de utilização de IA indicado.", }, "pl": { "Wszystkie informacje są publikowane pod jednym wspólnym odsyłaczem i są również dostępne jako maszynowo czytelny JSON-LD.", "Odpowiedzialność za treść i jej redakcyjną publikację pozostaje po stronie publikującej osoby lub organizacji, niezależnie od wskazanego zakresu użycia AI.", }, } end := closing[lang] end = append(end, assuranceStatement(d.Assurance, locale)) return []string{strings.Join(statements, " "), strings.Join(end, " ")} } func componentSummaryStatement(locale i18n.Locale, name string, component declaration.Component) string { componentName := locale.Components[name] if componentName == "" { componentName = name } extent := locale.Extents[component.AIExtent] activities := make([]string, 0, len(component.Activities)) for _, activity := range component.Activities { if value := locale.Activities[activity]; value != "" { activities = append(activities, value) } else { activities = append(activities, activity) } } activityText := strings.Join(activities, ", ") review := locale.Reviews[component.HumanReview] switch locale.Code { case "de": if component.AIExtent == "none" { if component.HumanReview == "none" { return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt.", componentName) } return fmt.Sprintf("Für den Bereich „%s“ wurde nach den vorliegenden Angaben keine KI eingesetzt; ergänzend ist eine %s menschliche Prüfung dokumentiert.", componentName, germanReviewAdjective(component.HumanReview)) } purpose := "" if activityText != "" { purpose = " für " + activityText } if component.HumanReview == "none" { return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; eine menschliche Prüfung der KI-bezogenen Ergebnisse ist nicht angegeben.", componentName, germanExtentPhrase(component.AIExtent), purpose) } return fmt.Sprintf("Für den Bereich „%s“ ist %s%s dokumentiert; die KI-bezogenen Ergebnisse wurden %s geprüft.", componentName, germanExtentPhrase(component.AIExtent), purpose, germanReviewAdverb(component.HumanReview)) case "en": if component.AIExtent == "none" { if component.HumanReview == "none" { return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName) } return fmt.Sprintf("According to the information provided, no AI was used for “%s”; a %s human review is nevertheless recorded.", componentName, strings.ToLower(review)) } purpose := "" if activityText != "" { purpose = " for " + activityText } if component.HumanReview == "none" { return fmt.Sprintf("For “%s”, %s%s is recorded; no human review of the AI-related output is specified.", componentName, strings.ToLower(extent), purpose) } return fmt.Sprintf("For “%s”, %s%s is recorded; the AI-related output underwent %s human review.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) default: if component.AIExtent == "none" { return genericNoAIStatement(locale.Code, componentName, component.HumanReview, review) } return genericAIStatement(locale.Code, componentName, extent, activityText, component.HumanReview, review) } } func germanExtentPhrase(extent string) string { switch extent { case "assisted": return "eine KI-unterstützte Nutzung" case "partial": return "eine teilweise KI-generierte Erstellung" case "mostly": return "eine überwiegend KI-generierte Erstellung" case "full": return "eine vollständig KI-generierte Erstellung" default: return "eine KI-Nutzung" } } func germanReviewAdverb(review string) string { switch review { case "basic": return "grundlegend" case "editorial": return "redaktionell" case "expert": return "fachlich" default: return "menschlich" } } func germanReviewAdjective(review string) string { switch review { case "basic": return "grundlegende" case "editorial": return "redaktionelle" case "expert": return "fachliche" default: return "menschliche" } } func genericNoAIStatement(lang, componentName, reviewCode, review string) string { withReview := reviewCode != "none" switch lang { case "fr": if withReview { return fmt.Sprintf("Selon les informations fournies, aucune IA n’a été utilisée pour « %s » ; une vérification humaine %s est néanmoins documentée.", componentName, strings.ToLower(review)) } return fmt.Sprintf("Selon les informations fournies, aucune IA n’a été utilisée pour « %s ».", componentName) case "es": if withReview { return fmt.Sprintf("Según la información proporcionada, no se utilizó IA en «%s»; no obstante, se documenta una revisión humana %s.", componentName, strings.ToLower(review)) } return fmt.Sprintf("Según la información proporcionada, no se utilizó IA en «%s».", componentName) case "it": if withReview { return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata l’IA; è comunque documentata una revisione umana %s.", componentName, strings.ToLower(review)) } return fmt.Sprintf("Secondo le informazioni fornite, per «%s» non è stata utilizzata l’IA.", componentName) case "nl": if withReview { return fmt.Sprintf("Volgens de verstrekte informatie is voor ‘%s’ geen AI gebruikt; er is wel een %s menselijke controle vastgelegd.", componentName, strings.ToLower(review)) } return fmt.Sprintf("Volgens de verstrekte informatie is voor ‘%s’ geen AI gebruikt.", componentName) case "pt": if withReview { return fmt.Sprintf("De acordo com as informações fornecidas, não foi utilizada IA em «%s»; está, ainda assim, documentada uma revisão humana %s.", componentName, strings.ToLower(review)) } return fmt.Sprintf("De acordo com as informações fornecidas, não foi utilizada IA em «%s».", componentName) case "pl": if withReview { return fmt.Sprintf("Zgodnie z podanymi informacjami w obszarze „%s” nie użyto AI; udokumentowano jednak weryfikację człowieka na poziomie %s.", componentName, review) } return fmt.Sprintf("Zgodnie z podanymi informacjami w obszarze „%s” nie użyto AI.", componentName) default: return fmt.Sprintf("According to the information provided, no AI was used for “%s”.", componentName) } } func genericAIStatement(lang, componentName, extent, activities string, reviewCode, review string) string { purpose := activities if purpose == "" { purpose = "—" } switch lang { case "fr": if reviewCode == "none" { return fmt.Sprintf("Pour « %s », l’utilisation déclarée est %s, avec pour finalité %s ; aucune vérification humaine des résultats liés à l’IA n’est indiquée.", componentName, strings.ToLower(extent), purpose) } return fmt.Sprintf("Pour « %s », l’utilisation déclarée est %s, avec pour finalité %s ; les résultats liés à l’IA ont fait l’objet d’une vérification humaine %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) case "es": if reviewCode == "none" { return fmt.Sprintf("Para «%s» se declara %s, con la finalidad %s; no se indica una revisión humana de los resultados relacionados con la IA.", componentName, strings.ToLower(extent), purpose) } return fmt.Sprintf("Para «%s» se declara %s, con la finalidad %s; los resultados relacionados con la IA fueron objeto de una revisión humana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) case "it": if reviewCode == "none" { return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; non è indicata una revisione umana dei risultati legati all’IA.", componentName, strings.ToLower(extent), purpose) } return fmt.Sprintf("Per «%s» è dichiarato %s, con finalità %s; i risultati legati all’IA sono stati sottoposti a revisione umana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) case "nl": if reviewCode == "none" { return fmt.Sprintf("Voor ‘%s’ is %s vastgelegd, met als doel %s; er is geen menselijke controle van de AI-gerelateerde resultaten aangegeven.", componentName, strings.ToLower(extent), purpose) } return fmt.Sprintf("Voor ‘%s’ is %s vastgelegd, met als doel %s; de AI-gerelateerde resultaten hebben een %s menselijke controle ondergaan.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) case "pt": if reviewCode == "none" { return fmt.Sprintf("Para «%s» está documentado %s, com a finalidade %s; não é indicada uma revisão humana dos resultados relacionados com IA.", componentName, strings.ToLower(extent), purpose) } return fmt.Sprintf("Para «%s» está documentado %s, com a finalidade %s; os resultados relacionados com IA foram sujeitos a uma revisão humana %s.", componentName, strings.ToLower(extent), purpose, strings.ToLower(review)) case "pl": if reviewCode == "none" { return fmt.Sprintf("Dla obszaru „%s” udokumentowano %s w celu %s; nie wskazano weryfikacji człowieka dla wyników związanych z AI.", componentName, extent, purpose) } return fmt.Sprintf("Dla obszaru „%s” udokumentowano %s w celu %s; wyniki związane z AI poddano weryfikacji człowieka na poziomie %s.", componentName, extent, purpose, review) default: return fmt.Sprintf("For “%s”, %s is recorded.", componentName, strings.ToLower(extent)) } } func assuranceStatement(assurance string, locale i18n.Locale) string { key := "assurance_" + assurance + "_statement" if value := locale.Text[key]; value != "" { if assurance == "signed" { switch locale.Code { case "de": return value + " Die Signatur bestätigt nicht automatisch die inhaltliche Richtigkeit der Angaben." case "en": return value + " The signature does not by itself confirm the substantive accuracy of the information." } } return value } if value := locale.Text["assurance_"+assurance+"_description"]; value != "" { return value } return locale.Assurances[assurance] } func customTextRequested(q url.Values, keys ...string) bool { for _, key := range keys { if strings.TrimSpace(q.Get(key)) != "" { return true } } return false } func firstNonEmpty(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" } func validHexColor(value string) bool { if len(value) != 7 || value[0] != '#' { return false } for _, r := range value[1:] { if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { return false } } return true } func languageCodes() []string { languages := i18n.Languages() out := make([]string, 0, len(languages)) for _, l := range languages { out = append(out, l.Code) } sort.Strings(out) return out } func cloneValues(in url.Values) url.Values { out := url.Values{} for k, v := range in { out[k] = append([]string(nil), v...) } return out } func randomID() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b) } func clientIP(r *http.Request, trustProxy bool) string { if trustProxy { if x := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); x != "" { return x } } return r.RemoteAddr } func ensureEOF(dec *json.Decoder) error { var extra any err := dec.Decode(&extra) if errors.Is(err, io.EOF) { return nil } if err == nil { return errors.New("request body must contain exactly one JSON value") } return err } const declarationSchema = `{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "__BASE_URL__/schema/v1/declaration.schema.json", "title": "AI Usage Declaration", "type": "object", "additionalProperties": false, "required": ["@context", "@type", "schemaVersion", "language", "components", "assurance"], "properties": { "@context": {"type": "string"}, "@type": {"const": "AIUsageDeclaration"}, "schemaVersion": {"const": "1.1"}, "subject": {"type": "string", "format": "uri"}, "declaredAt": {"type": "string", "format": "date-time"}, "language": {"enum": ["de", "en", "fr", "es", "it", "nl", "pt", "pl"]}, "assurance": {"enum": ["selfDeclared", "technicallyRecorded", "signed", "verified"]}, "editorialResponsibility": { "type": "object", "additionalProperties": false, "properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}} }, "presentation": { "type": "object", "additionalProperties": false, "properties": { "title": {"type": "string", "maxLength": 120}, "description": {"type": "string", "maxLength": 500}, "badgeLabel": {"type": "string", "maxLength": 40}, "badgeMessage": {"type": "string", "maxLength": 80}, "leftColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}, "rightColor": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"} } }, "components": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/component"}} }, "$defs": { "component": { "type": "object", "additionalProperties": false, "required": ["aiExtent", "humanReview"], "properties": { "aiExtent": {"enum": ["none", "assisted", "partial", "mostly", "full"]}, "activities": {"type": "array", "uniqueItems": true, "items": {"enum": ["research", "summarisation", "drafting", "generation", "translation", "editing", "imageGeneration", "codeGeneration", "transcription", "classification"]}}, "humanReview": {"enum": ["none", "basic", "editorial", "expert"]}, "note": {"type": "string", "maxLength": 500} } } } }` const jsonLDContext = `{ "@context": { "@version": 1.1, "AIUsageDeclaration": "__BASE_URL__/vocab/AIUsageDeclaration", "schemaVersion": "__BASE_URL__/vocab/schemaVersion", "subject": {"@id": "https://schema.org/about", "@type": "@id"}, "declaredAt": {"@id": "https://schema.org/dateCreated", "@type": "https://www.w3.org/2001/XMLSchema#dateTime"}, "language": "https://schema.org/inLanguage", "components": "__BASE_URL__/vocab/components", "aiExtent": "__BASE_URL__/vocab/aiExtent", "activities": "__BASE_URL__/vocab/activities", "humanReview": "__BASE_URL__/vocab/humanReview", "assurance": "__BASE_URL__/vocab/assurance", "presentation": "__BASE_URL__/vocab/presentation", "editorialResponsibility": "https://schema.org/accountablePerson" } }`