Übernimm eine Kennzeichnung aus dem normalen Generator, ergänze wiederkehrende Website-Daten und erzeuge HTML, Markdown und JSON-LD für bis zu {{.MaxURLs}} Inhalts-URLs in einem Lauf.
+
Datensparsam: Inhalts-URLs werden nicht abgerufen. Website-Profile und gespeicherte Vorlagen bleiben ausschließlich im Browser-Speicher dieses Geräts. URL-Listen und Ergebnisse werden nicht dauerhaft gespeichert.
+
+
+
+
+
1 · Kennzeichnung
+
Vorlage festlegen
+
Am sichersten ist die Übernahme direkt aus dem bestehenden Generator. Alternativ kannst du eine Declaration-URL oder deren Query-String einfügen.
Hier gehören Angaben hinein, die du nicht bei jedem Batch erneut eingeben möchtest. Nicht ausgefüllte Profilfelder verändern die geladene Kennzeichnungsvorlage nicht.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Autor / Byline optional
+
+
+
+
+
+
+
+
Redaktionelle Verantwortung nur eintragen, wenn sie zur Vorlage passt
+
+
+
+
+
+
+
+
+
Beschwerde- / Rückmeldestelle Best Practice
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gespeichert werden nur die von dir angelegten Profile und Vorlagen in localStorage. Eine Synchronisierung zum Server findet nicht statt.
+
+
+
+
+
3 · Inhalte
+
URLs oder Pfade einfügen
+
Eine URL bzw. ein Pfad pro Zeile. Bei relativen Pfaden wird die optionale Basis-URL verwendet. Eine einspaltige CSV-Datei mit der Überschrift url kann ebenfalls direkt eingefügt werden.
+
+
+
+
0gültige URLs
+
+
+
+
+
+
+
+
+
+
+
+
4 · Ausgabe
+
Ergebnisse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Inhalt
Status
Links
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Was dieser Container bewusst nicht tut
+
+
keine Benutzerkonten und keine Datenbank
+
kein Crawling und kein Abruf der eingegebenen Inhalts-URLs
+
keine eigene rechtliche Entscheidungslogik
+
keine dauerhafte Speicherung von URL-Listen oder Ergebnissen
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/cmd/bulk/main.go b/cmd/bulk/main.go
new file mode 100644
index 0000000..b0e5765
--- /dev/null
+++ b/cmd/bulk/main.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/b1tsblog/ai-disclosure-standard/internal/bulk"
+)
+
+func main() {
+ if len(os.Args) > 1 && os.Args[1] == "--healthcheck" {
+ url := os.Getenv("BULK_HEALTHCHECK_URL")
+ if url == "" {
+ url = "http://127.0.0.1:8081/healthz"
+ }
+ client := &http.Client{Timeout: 2 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ os.Exit(1)
+ }
+ _ = resp.Body.Close()
+ return
+ }
+
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
+ cfg := bulk.ConfigFromEnv()
+ handler, err := bulk.New(cfg, logger)
+ if err != nil {
+ logger.Error("bulk application initialization failed", "error", err)
+ os.Exit(1)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+ server := &http.Server{
+ Addr: cfg.ListenAddress, Handler: handler,
+ ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second,
+ WriteTimeout: 90 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20,
+ }
+
+ go func() {
+ logger.Info("bulk server started", "address", cfg.ListenAddress, "core", cfg.CoreInternalURL)
+ if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ logger.Error("bulk server failed", "error", err)
+ os.Exit(1)
+ }
+ }()
+
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := server.Shutdown(shutdownCtx); err != nil {
+ logger.Error("bulk graceful shutdown failed", "error", err)
+ os.Exit(1)
+ }
+}
diff --git a/compose.yaml b/compose.yaml
index ca2a620..ba81798 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,8 +1,13 @@
services:
app:
- image: git.send.nrw/sendnrw/ai-disclosure-standard:latest
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: ai-disclosure-standard:1.8.0-local
env_file:
- .env
+ environment:
+ BULK_URL: ${BULK_URL:-http://localhost:8081}
ports:
- "8080:8080"
volumes:
@@ -22,5 +27,37 @@ services:
retries: 3
start_period: 5s
+ bulk:
+ build:
+ context: .
+ dockerfile: Dockerfile.bulk
+ image: ai-disclosure-bulk:1.0.0-local
+ environment:
+ CORE_INTERNAL_URL: http://app:8080
+ DISCLOSURE_BASE_URL: ${BASE_URL:-http://localhost:8080}
+ GENERATOR_URL: ${BASE_URL:-http://localhost:8080}
+ BULK_PUBLIC_NAME: AI Disclosure Bulk
+ BULK_MAX_URLS: ${BULK_MAX_URLS:-500}
+ BULK_WORKERS: ${BULK_WORKERS:-4}
+ ports:
+ - "8081:8081"
+ read_only: true
+ tmpfs:
+ - /tmp:size=8m,mode=1777
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ depends_on:
+ app:
+ condition: service_healthy
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "/ai-disclosure-bulk", "--healthcheck"]
+ interval: 15s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
+
volumes:
license-cache:
diff --git a/deploy/kubernetes.yaml b/deploy/kubernetes.yaml
index ae9c36d..70def9e 100644
--- a/deploy/kubernetes.yaml
+++ b/deploy/kubernetes.yaml
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: app
- image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.7.0
+ image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.0
imagePullPolicy: IfNotPresent
ports:
- name: http
diff --git a/deploy/swarm-stack.yaml b/deploy/swarm-stack.yaml
index 7c684d1..79fd1b2 100644
--- a/deploy/swarm-stack.yaml
+++ b/deploy/swarm-stack.yaml
@@ -1,7 +1,7 @@
version: "3.9"
services:
app:
- image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.7.0
+ image: ghcr.io/REPLACE_ME/ai-disclosure-standard:1.8.0
environment:
BASE_URL: https://ai.example.org
PUBLIC_NAME: AI Usage Disclosure
diff --git a/docs/BACKGROUND-PAGE.md b/docs/BACKGROUND-PAGE.md
index aafb229..37d72df 100644
--- a/docs/BACKGROUND-PAGE.md
+++ b/docs/BACKGROUND-PAGE.md
@@ -1,6 +1,6 @@
# Mehrsprachige Hintergrundseite
-Version 1.7.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
+Version 1.8.0 stellt unter `/background` eine eigenständige Informationsseite zur KI-Kennzeichnung und zu Artikel 50 des EU AI Act bereit.
## Routen
diff --git a/docs/LICENSE-CLIENT.md b/docs/LICENSE-CLIENT.md
index 35dd6c9..e3d53fd 100644
--- a/docs/LICENSE-CLIENT.md
+++ b/docs/LICENSE-CLIENT.md
@@ -16,7 +16,7 @@ Initialization is performed in `internal/app/server.go`. The product ID and embe
```go
licenses := licenseclient.New(ctx, licenseclient.Config{
Product: "ai-disclosure-standard",
- ClientVersion: "1.7.0",
+ ClientVersion: "1.8.0",
Token: cfg.LicenseToken,
TrustStore: trustStore,
BaseURL: cfg.BaseURL,
diff --git a/docs/LICENSE-INTEGRATION.md b/docs/LICENSE-INTEGRATION.md
index 90ef2cf..104abbb 100644
--- a/docs/LICENSE-INTEGRATION.md
+++ b/docs/LICENSE-INTEGRATION.md
@@ -119,7 +119,7 @@ Anfrage:
"baseUrl": "https://ai.example.org",
"host": "ai.example.org",
"instanceId": "production-eu-1",
- "clientVersion": "1.7.0"
+ "clientVersion": "1.8.0"
}
```
diff --git a/internal/app/config.go b/internal/app/config.go
index 5ab6d5c..51a2fa1 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -21,6 +21,7 @@ type Config struct {
PublicName string
ContactURL string
SalesURL string
+ BulkURL string
DefaultLanguage string
TrustProxy bool
TrustedProxies []netip.Prefix
@@ -89,6 +90,7 @@ func ConfigFromEnv() Config {
PublicName: env("PUBLIC_NAME", "AI Usage Disclosure"),
ContactURL: contactURL,
SalesURL: env("SALES_URL", contactURL),
+ BulkURL: strings.TrimRight(env("BULK_URL", ""), "/"),
DefaultLanguage: env("DEFAULT_LANGUAGE", "de"),
TrustProxy: trustProxy,
TrustedProxies: trustedProxies,
@@ -159,6 +161,7 @@ func validateConfig(cfg Config) error {
}{
{"CONTACT_URL", cfg.ContactURL},
{"SALES_URL", cfg.SalesURL},
+ {"BULK_URL", cfg.BulkURL},
{"SUPERVISORY_AUTHORITY_URL", cfg.SupervisoryAuthorityURL},
{"CONSUMER_DISPUTE_URL", cfg.ConsumerDisputeURL},
} {
diff --git a/internal/app/legal.go b/internal/app/legal.go
index df539ae..1529b39 100644
--- a/internal/app/legal.go
+++ b/internal/app/legal.go
@@ -163,7 +163,7 @@ func privacyPage(cfg Config, lang string) legalPage {
{
Title: "4. Cookies, Tracking und lokale Speicherung",
Paragraphs: []string{
- "Die mitgelieferte Weboberfläche setzt keine Cookies, verwendet kein Webtracking und speichert keine Daten in Local Storage oder Session Storage. Wird die Anwendung um Analyse-, Marketing-, Schrift-, Karten-, Video- oder andere Drittinhalte erweitert, muss die Datenschutzerklärung angepasst und eine gegebenenfalls erforderliche Einwilligung vor dem Zugriff auf das Endgerät eingeholt werden.",
+ "Die Kern-Weboberfläche setzt keine Cookies, verwendet kein Webtracking und speichert keine Daten in Local Storage oder Session Storage. Der optionale Bulk-Container speichert vom Nutzer angelegte Website-Profile und Kennzeichnungsvorlagen ausschließlich lokal im Local Storage des jeweiligen Browsers; URL-Listen und erzeugte Ergebnisse werden dort nicht dauerhaft gespeichert und nicht zum Server synchronisiert. Lokale Bulk-Daten können exportiert, importiert oder vollständig gelöscht werden. Wird die Anwendung um Analyse-, Marketing-, Schrift-, Karten-, Video- oder andere Drittinhalte erweitert, muss die Datenschutzerklärung angepasst und eine gegebenenfalls erforderliche Einwilligung vor dem Zugriff auf das Endgerät eingeholt werden.",
},
},
{
@@ -208,7 +208,7 @@ func privacyPage(cfg Config, lang string) legalPage {
"The generator is stateless and does not store inputs in an application database. Inputs are nevertheless processed as URL parameters and may appear in browser history and proxy or access logs.",
"Generated declaration and JSON-LD URLs are designed for public embedding. Do not enter confidential data, special-category personal data or unnecessary personal information.",
}},
- {Title: "4. Cookies and tracking", Paragraphs: []string{"The bundled interface sets no cookies, uses no web tracking and does not store data in Local Storage or Session Storage. Operators adding analytics, marketing or third-party embeds must update this notice and obtain any legally required consent before accessing the user's device."}},
+ {Title: "4. Cookies and tracking", Paragraphs: []string{"The core interface sets no cookies, uses no web tracking and does not store data in Local Storage or Session Storage. The optional bulk container stores user-created site profiles and disclosure templates only in the respective browser's Local Storage; URL lists and generated results are not persistently stored there and are not synchronised to the server. Local bulk data can be exported, imported or deleted completely. Operators adding analytics, marketing or third-party embeds must update this notice and obtain any legally required consent before accessing the user's device."}},
{Title: "5. Hosting and recipients", Fields: compactFields([]legalField{{Label: "Hosting provider", Value: requiredValue(cfg.HostingProvider)}, {Label: "Address / region", Value: cfg.HostingAddress}, {Label: "Other recipients", Value: cfg.DataRecipients}, {Label: "Third-country transfers", Value: cfg.ThirdCountryTransfers}})},
{Title: "6. Optional licence validation", Paragraphs: []string{"Offline mode performs no online licence validation. Hybrid and online modes send the licence token, product identifier, public base URL, host, optional instance ID and client version to the configured licence server. The operator must document that service separately."}},
{Title: "7. Data-subject rights", Paragraphs: []string{"Subject to the GDPR, individuals may have rights of access, rectification, erasure, restriction, portability, objection and withdrawal of consent, as well as the right to complain to a supervisory authority."}, Fields: compactFields([]legalField{{Label: "Supervisory authority", Value: cfg.SupervisoryAuthorityName, URL: cfg.SupervisoryAuthorityURL}})},
diff --git a/internal/app/licensing.go b/internal/app/licensing.go
index 337a87a..5ffcbca 100644
--- a/internal/app/licensing.go
+++ b/internal/app/licensing.go
@@ -9,7 +9,7 @@ import (
const (
ProductID = "ai-disclosure-standard"
- ProductVersion = "1.7.0"
+ ProductVersion = "1.8.0"
FeatureCustomText = "custom_text"
FeatureCustomBadge = "custom_badge"
FeatureWhiteLabel = "white_label"
diff --git a/internal/app/server.go b/internal/app/server.go
index 64de257..9361e22 100644
--- a/internal/app/server.go
+++ b/internal/app/server.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "html"
"html/template"
"io"
"io/fs"
@@ -92,6 +93,7 @@ type clientConfig struct {
SelectedLanguage string `json:"selectedLanguage"`
Locales map[string]i18n.Locale `json:"locales"`
Capabilities map[string]bool `json:"capabilities"`
+ BulkURL string `json:"bulkURL,omitempty"`
}
type publicLicenseStatus struct {
@@ -141,6 +143,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /v1/badge.svg", s.handleBadge)
s.mux.HandleFunc("GET /declaration", s.handleDeclaration)
s.mux.HandleFunc("GET /v1/declaration.json", s.handleManifest)
+ s.mux.HandleFunc("GET /v1/render", s.handleRender)
s.mux.HandleFunc("POST /v1/validate", s.handleValidate)
s.mux.HandleFunc("GET /v1/capabilities", s.handleCapabilities)
s.mux.HandleFunc("GET /schema/v1/declaration.schema.json", s.handleSchema)
@@ -161,6 +164,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
locale := i18n.Get(lang)
cfg := clientConfig{
BaseURL: s.cfg.BaseURL, SelectedLanguage: lang, Locales: i18n.ClientCatalogs(),
+ BulkURL: s.cfg.BulkURL,
Capabilities: map[string]bool{
FeatureCustomText: s.licenses.Has(FeatureCustomText),
FeatureCustomBadge: s.licenses.Has(FeatureCustomBadge),
@@ -458,6 +462,93 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(d)
}
+type renderedArtifacts struct {
+ Subject string `json:"subject,omitempty"`
+ DeclarationURL string `json:"declarationUrl"`
+ BadgeURL string `json:"badgeUrl"`
+ ManifestURL string `json:"manifestUrl"`
+ HTML string `json:"html"`
+ Markdown string `json:"markdown"`
+ JSONLD declaration.Declaration `json:"jsonLd"`
+}
+
+func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
+ q := cloneValues(r.URL.Query())
+ theme := strings.TrimSpace(q.Get("theme"))
+ q.Del("theme")
+ if theme == "" {
+ theme = "mono"
+ }
+ if theme != "mono" && theme != "color" && theme != "emoji" {
+ s.problem(w, http.StatusBadRequest, "invalid_theme", "theme must be one of mono, color or emoji")
+ return
+ }
+ q.Set("lang", s.languageFromValues(r, q))
+ d, err := s.declarationFromQuery(q)
+ if err != nil {
+ s.declarationError(w, err)
+ return
+ }
+
+ locale := i18n.Get(d.Language)
+ declarationURL := s.cfg.BaseURL + "/declaration?" + q.Encode()
+ manifestURL := s.cfg.BaseURL + "/v1/declaration.json?" + q.Encode()
+ badgeQ := cloneValues(q)
+ badgeQ.Set("theme", theme)
+ badgeQ.Set("link", declarationURL)
+ badgeURL := s.cfg.BaseURL + "/v1/badge.svg?" + badgeQ.Encode()
+
+ assessment := assessLegalContext(d, locale)
+ alt := renderBadgeAlt(q, d, locale, assessment)
+ imageMarkup := fmt.Sprintf(``, html.EscapeString(declarationURL), html.EscapeString(badgeURL), html.EscapeString(alt))
+ htmlMarkup := imageMarkup
+ markdown := fmt.Sprintf(`[](%s)`, markdownAlt(alt), badgeURL, declarationURL)
+ if assessment.RequiresDisclosure && theme == "emoji" {
+ htmlMarkup += " " + html.EscapeString(assessment.BadgeMessage) + ""
+ markdown += " **" + markdownText(assessment.BadgeMessage) + "**"
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(renderedArtifacts{
+ Subject: d.Subject, DeclarationURL: declarationURL, BadgeURL: badgeURL, ManifestURL: manifestURL,
+ HTML: htmlMarkup, Markdown: markdown, JSONLD: d,
+ })
+}
+
+func renderBadgeAlt(q url.Values, d declaration.Declaration, locale i18n.Locale, assessment legalAssessment) string {
+ if assessment.RequiresDisclosure && assessment.BadgeMessage != "" {
+ return assessment.BadgeMessage
+ }
+ if q.Get("mode") == "article" || len(d.Components) > 1 {
+ if value := locale.Text["badge_article"]; value != "" {
+ return value
+ }
+ }
+ if preset, ok := locale.Presets[q.Get("preset")]; ok && preset.Title != "" {
+ return preset.Title
+ }
+ for _, component := range d.Components {
+ if value := locale.Extents[component.AIExtent]; value != "" {
+ return value
+ }
+ }
+ return "AI usage disclosure"
+}
+
+func markdownAlt(value string) string {
+ value = strings.ReplaceAll(value, `\`, `\\`)
+ value = strings.ReplaceAll(value, `]`, `\]`)
+ return strings.ReplaceAll(value, `[`, `\[`)
+}
+
+func markdownText(value string) string {
+ for _, marker := range []string{"\\", "*", "_", "`", "[", "]"} {
+ value = strings.ReplaceAll(value, marker, "\\"+marker)
+ }
+ return value
+}
+
func (s *Server) declarationFromQuery(q url.Values) (declaration.Declaration, error) {
return declaration.NewFromQueryWithOptions(q, s.cfg.BaseURL+"/context/v1", declaration.ParseOptions{
AllowCustomText: s.licenses.Has(FeatureCustomText), AllowCustomBadge: s.licenses.Has(FeatureCustomBadge), DefaultLanguage: s.cfg.DefaultLanguage,
diff --git a/internal/app/server_test.go b/internal/app/server_test.go
index cbe2bfd..c12ea21 100644
--- a/internal/app/server_test.go
+++ b/internal/app/server_test.go
@@ -686,3 +686,55 @@ func TestUnclearDeepfakeAssessmentUsesCautiousDisclosureGuidance(t *testing.T) {
}
}
}
+
+func TestRenderEndpointReturnsReusableArtifacts(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "/v1/render?component=text&extent=partial&review=editorial&lang=de&subject=https%3A%2F%2Fcontent.example%2Farticle&theme=mono", nil)
+ w := httptest.NewRecorder()
+ testHandler(t).ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ var payload struct {
+ Subject string `json:"subject"`
+ DeclarationURL string `json:"declarationUrl"`
+ BadgeURL string `json:"badgeUrl"`
+ ManifestURL string `json:"manifestUrl"`
+ HTML string `json:"html"`
+ Markdown string `json:"markdown"`
+ JSONLD map[string]any `json:"jsonLd"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload.Subject != "https://content.example/article" {
+ t.Fatalf("subject %q", payload.Subject)
+ }
+ for name, value := range map[string]string{"declaration": payload.DeclarationURL, "badge": payload.BadgeURL, "manifest": payload.ManifestURL, "html": payload.HTML, "markdown": payload.Markdown} {
+ if value == "" {
+ t.Fatalf("%s is empty", name)
+ }
+ }
+ if got, _ := payload.JSONLD["subject"].(string); got != payload.Subject {
+ t.Fatalf("jsonLd subject %q", got)
+ }
+ if strings.Contains(payload.DeclarationURL, "theme=") {
+ t.Fatalf("theme leaked into declaration URL: %s", payload.DeclarationURL)
+ }
+ if !strings.Contains(payload.BadgeURL, "theme=mono") {
+ t.Fatalf("badge URL missing theme: %s", payload.BadgeURL)
+ }
+}
+
+func TestGeneratorExposesBulkHandoffWhenConfigured(t *testing.T) {
+ h := testHandlerConfig(t, Config{ListenAddress: ":0", BaseURL: "https://example.org", BulkURL: "https://bulk.example.org", PublicName: "Test", DefaultLanguage: "de"})
+ r := httptest.NewRequest(http.MethodGet, "/?lang=de", nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, `id="bulk-template-link"`) || !strings.Contains(body, `"bulkURL":"https://bulk.example.org"`) {
+ t.Fatalf("bulk handoff missing: %s", body)
+ }
+}
diff --git a/internal/bulk/config.go b/internal/bulk/config.go
new file mode 100644
index 0000000..9d48e8d
--- /dev/null
+++ b/internal/bulk/config.go
@@ -0,0 +1,98 @@
+package bulk
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Config struct {
+ ListenAddress string
+ CoreInternalURL string
+ DisclosureBaseURL string
+ GeneratorURL string
+ PublicName string
+ MaxURLs int
+ Workers int
+ RequestTimeout time.Duration
+}
+
+func ConfigFromEnv() Config {
+ return Config{
+ ListenAddress: env("BULK_LISTEN_ADDRESS", ":8081"),
+ CoreInternalURL: strings.TrimRight(env("CORE_INTERNAL_URL", "http://app:8080"), "/"),
+ DisclosureBaseURL: strings.TrimRight(env("DISCLOSURE_BASE_URL", "http://localhost:8080"), "/"),
+ GeneratorURL: strings.TrimRight(env("GENERATOR_URL", env("DISCLOSURE_BASE_URL", "http://localhost:8080")), "/"),
+ PublicName: env("BULK_PUBLIC_NAME", "AI Disclosure Bulk"),
+ MaxURLs: intEnv("BULK_MAX_URLS", 500),
+ Workers: intEnv("BULK_WORKERS", 4),
+ RequestTimeout: durationEnv("BULK_REQUEST_TIMEOUT", 8*time.Second),
+ }
+}
+
+func ValidateConfig(cfg Config) error {
+ for name, value := range map[string]string{
+ "CORE_INTERNAL_URL": cfg.CoreInternalURL,
+ "DISCLOSURE_BASE_URL": cfg.DisclosureBaseURL,
+ "GENERATOR_URL": cfg.GeneratorURL,
+ } {
+ if err := validateOrigin(name, value); err != nil {
+ return err
+ }
+ }
+ if strings.TrimSpace(cfg.PublicName) == "" {
+ return fmt.Errorf("BULK_PUBLIC_NAME must not be empty")
+ }
+ if cfg.MaxURLs < 1 || cfg.MaxURLs > 5000 {
+ return fmt.Errorf("BULK_MAX_URLS must be between 1 and 5000")
+ }
+ if cfg.Workers < 1 || cfg.Workers > 32 {
+ return fmt.Errorf("BULK_WORKERS must be between 1 and 32")
+ }
+ if cfg.RequestTimeout < time.Second || cfg.RequestTimeout > 60*time.Second {
+ return fmt.Errorf("BULK_REQUEST_TIMEOUT must be between 1s and 60s")
+ }
+ return nil
+}
+
+func validateOrigin(name, value string) error {
+ u, err := url.Parse(value)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
+ return fmt.Errorf("%s must be an absolute http(s) origin without path, query, credentials or fragment", name)
+ }
+ return nil
+}
+
+func env(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func intEnv(key string, fallback int) int {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ return fallback
+ }
+ parsed, err := strconv.Atoi(value)
+ if err != nil {
+ return fallback
+ }
+ return parsed
+}
+
+func durationEnv(key string, fallback time.Duration) time.Duration {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ return fallback
+ }
+ parsed, err := time.ParseDuration(value)
+ if err != nil {
+ return fallback
+ }
+ return parsed
+}
diff --git a/internal/bulk/server.go b/internal/bulk/server.go
new file mode 100644
index 0000000..2687e04
--- /dev/null
+++ b/internal/bulk/server.go
@@ -0,0 +1,279 @@
+package bulk
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ bulkweb "github.com/b1tsblog/ai-disclosure-standard/bulkweb"
+)
+
+const Version = "1.0.0"
+
+type Server struct {
+ cfg Config
+ logger *slog.Logger
+ templates *template.Template
+ client *http.Client
+ mux *http.ServeMux
+}
+
+type pageData struct {
+ Name string
+ Version string
+ DisclosureBaseURL string
+ GeneratorURL string
+ MaxURLs int
+}
+
+type publicConfig struct {
+ DisclosureBaseURL string `json:"disclosureBaseURL"`
+ GeneratorURL string `json:"generatorURL"`
+ MaxURLs int `json:"maxURLs"`
+ Version string `json:"version"`
+}
+
+type batchRequest struct {
+ Template string `json:"template"`
+ Subjects []string `json:"subjects"`
+}
+
+type batchResult struct {
+ Subject string `json:"subject"`
+ OK bool `json:"ok"`
+ Data json.RawMessage `json:"data,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type batchResponse struct {
+ Results []batchResult `json:"results"`
+}
+
+func New(cfg Config, logger *slog.Logger) (http.Handler, error) {
+ if err := ValidateConfig(cfg); err != nil {
+ return nil, err
+ }
+ tmpl, err := template.New("root").ParseFS(bulkweb.Files, "templates/*.html")
+ if err != nil {
+ return nil, fmt.Errorf("parse bulk templates: %w", err)
+ }
+ s := &Server{
+ cfg: cfg,
+ logger: logger,
+ templates: tmpl,
+ client: &http.Client{
+ Timeout: cfg.RequestTimeout,
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ },
+ mux: http.NewServeMux(),
+ }
+ s.routes()
+ return s.securityHeaders(s.mux), nil
+}
+
+func (s *Server) routes() {
+ staticFS, _ := fs.Sub(bulkweb.Files, "static")
+ s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
+ s.mux.HandleFunc("GET /", s.handleIndex)
+ s.mux.HandleFunc("GET /api/config", s.handleConfig)
+ s.mux.HandleFunc("POST /api/render-batch", s.handleRenderBatch)
+ s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "ok\n")
+ })
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = s.templates.ExecuteTemplate(w, "index.html", pageData{
+ Name: s.cfg.PublicName, Version: Version, DisclosureBaseURL: s.cfg.DisclosureBaseURL,
+ GeneratorURL: s.cfg.GeneratorURL, MaxURLs: s.cfg.MaxURLs,
+ })
+}
+
+func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(publicConfig{
+ DisclosureBaseURL: s.cfg.DisclosureBaseURL,
+ GeneratorURL: s.cfg.GeneratorURL,
+ MaxURLs: s.cfg.MaxURLs,
+ Version: Version,
+ })
+}
+
+func (s *Server) handleRenderBatch(w http.ResponseWriter, r *http.Request) {
+ body := http.MaxBytesReader(w, r.Body, 1<<20)
+ defer body.Close()
+ dec := json.NewDecoder(body)
+ dec.DisallowUnknownFields()
+ var request batchRequest
+ if err := dec.Decode(&request); err != nil {
+ s.problem(w, http.StatusBadRequest, "invalid_json", "Request body must be valid JSON.")
+ return
+ }
+ if err := ensureEOF(dec); err != nil {
+ s.problem(w, http.StatusBadRequest, "invalid_json", "Request body must contain exactly one JSON object.")
+ return
+ }
+ if len(request.Subjects) == 0 {
+ s.problem(w, http.StatusBadRequest, "missing_subjects", "At least one subject URL is required.")
+ return
+ }
+ if len(request.Subjects) > s.cfg.MaxURLs {
+ s.problem(w, http.StatusRequestEntityTooLarge, "too_many_subjects", fmt.Sprintf("At most %d subject URLs are allowed per batch.", s.cfg.MaxURLs))
+ return
+ }
+ if len(request.Template) > 24<<10 {
+ s.problem(w, http.StatusRequestEntityTooLarge, "template_too_large", "Template query is too large.")
+ return
+ }
+
+ templateValues, err := url.ParseQuery(strings.TrimPrefix(strings.TrimSpace(request.Template), "?"))
+ if err != nil {
+ s.problem(w, http.StatusBadRequest, "invalid_template", "Template must be a valid URL query string.")
+ return
+ }
+ templateValues.Del("subject")
+
+ results := make([]batchResult, len(request.Subjects))
+ jobs := make(chan int)
+ var wg sync.WaitGroup
+ workers := min(s.cfg.Workers, len(request.Subjects))
+ for range workers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for idx := range jobs {
+ subject := strings.TrimSpace(request.Subjects[idx])
+ results[idx] = s.renderOne(r.Context(), templateValues, subject)
+ }
+ }()
+ }
+ for idx := range request.Subjects {
+ jobs <- idx
+ }
+ close(jobs)
+ wg.Wait()
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(batchResponse{Results: results})
+}
+
+func (s *Server) renderOne(ctx context.Context, templateValues url.Values, subject string) batchResult {
+ if err := validateSubject(subject); err != nil {
+ return batchResult{Subject: subject, Error: err.Error()}
+ }
+ values := cloneValues(templateValues)
+ values.Set("subject", subject)
+ endpoint := s.cfg.CoreInternalURL + "/v1/render?" + values.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return batchResult{Subject: subject, Error: "could not build core request"}
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := s.client.Do(req)
+ if err != nil {
+ s.logger.Warn("core render failed", "error", err)
+ return batchResult{Subject: subject, Error: "disclosure core is unavailable"}
+ }
+ defer resp.Body.Close()
+ payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return batchResult{Subject: subject, Error: "could not read disclosure core response"}
+ }
+ if resp.StatusCode != http.StatusOK {
+ message := coreErrorMessage(payload)
+ if message == "" {
+ message = fmt.Sprintf("disclosure core returned HTTP %d", resp.StatusCode)
+ }
+ return batchResult{Subject: subject, Error: message}
+ }
+ if !json.Valid(payload) {
+ return batchResult{Subject: subject, Error: "disclosure core returned invalid JSON"}
+ }
+ return batchResult{Subject: subject, OK: true, Data: json.RawMessage(payload)}
+}
+
+func validateSubject(raw string) error {
+ u, err := url.ParseRequestURI(raw)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
+ return errors.New("subject must be an absolute http(s) URL without credentials")
+ }
+ return nil
+}
+
+func coreErrorMessage(payload []byte) string {
+ var problem struct {
+ Detail string `json:"detail"`
+ Title string `json:"title"`
+ }
+ if json.Unmarshal(payload, &problem) != nil {
+ return ""
+ }
+ if strings.TrimSpace(problem.Detail) != "" {
+ return problem.Detail
+ }
+ return strings.TrimSpace(problem.Title)
+}
+
+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("unexpected second JSON value")
+ }
+ return err
+}
+
+func cloneValues(in url.Values) url.Values {
+ out := make(url.Values, len(in))
+ for key, values := range in {
+ out[key] = append([]string(nil), values...)
+ }
+ return out
+}
+
+func (s *Server) problem(w http.ResponseWriter, status int, code, detail string) {
+ w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "type": "about:blank", "title": code, "status": status, "detail": detail,
+ })
+}
+
+func (s *Server) securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
+ w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
+ started := time.Now()
+ next.ServeHTTP(w, r)
+ s.logger.Debug("request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(started))
+ })
+}
diff --git a/internal/bulk/server_test.go b/internal/bulk/server_test.go
new file mode 100644
index 0000000..bf671d7
--- /dev/null
+++ b/internal/bulk/server_test.go
@@ -0,0 +1,125 @@
+package bulk
+
+import (
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func testLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
+
+func TestRenderBatchUsesFixedCoreAndReplacesSubject(t *testing.T) {
+ var mu sync.Mutex
+ var subjects []string
+ core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/render" {
+ t.Fatalf("unexpected core path %q", r.URL.Path)
+ }
+ if got := r.URL.Query().Get("extent"); got != "partial" {
+ t.Fatalf("template extent = %q", got)
+ }
+ subject := r.URL.Query().Get("subject")
+ mu.Lock()
+ subjects = append(subjects, subject)
+ mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "subject": subject,
+ "declarationUrl": "https://public.example/declaration",
+ "badgeUrl": "https://public.example/badge.svg",
+ "manifestUrl": "https://public.example/manifest.json",
+ "html": "ok",
+ "markdown": "[ok]",
+ "jsonLd": map[string]any{"@type": "AIUsageDeclaration", "subject": subject},
+ })
+ }))
+ defer core.Close()
+
+ h, err := New(Config{
+ ListenAddress: ":0", CoreInternalURL: core.URL,
+ DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example",
+ PublicName: "Bulk", MaxURLs: 10, Workers: 2, RequestTimeout: 2 * time.Second,
+ }, testLogger())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ body := `{"template":"extent=partial&subject=https%3A%2F%2Fold.example%2Fignored","subjects":["https://content.example/a","https://content.example/b"]}`
+ r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ var response batchResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
+ t.Fatal(err)
+ }
+ if len(response.Results) != 2 || !response.Results[0].OK || !response.Results[1].OK {
+ t.Fatalf("unexpected results: %#v", response.Results)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if len(subjects) != 2 {
+ t.Fatalf("core calls = %d", len(subjects))
+ }
+ seen := map[string]bool{}
+ for _, subject := range subjects {
+ seen[subject] = true
+ }
+ if !seen["https://content.example/a"] || !seen["https://content.example/b"] || seen["https://old.example/ignored"] {
+ t.Fatalf("subjects sent to core: %#v", subjects)
+ }
+}
+
+func TestRenderBatchRejectsNonHTTPSubjectWithoutCallingCore(t *testing.T) {
+ calls := 0
+ core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer core.Close()
+
+ h, err := New(Config{ListenAddress: ":0", CoreInternalURL: core.URL, DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", PublicName: "Bulk", MaxURLs: 10, Workers: 1, RequestTimeout: 2 * time.Second}, testLogger())
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(`{"template":"extent=partial","subjects":["file:///etc/passwd"]}`))
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ var response batchResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
+ t.Fatal(err)
+ }
+ if len(response.Results) != 1 || response.Results[0].OK || !strings.Contains(response.Results[0].Error, "http(s)") {
+ t.Fatalf("unexpected response: %#v", response.Results)
+ }
+ if calls != 0 {
+ t.Fatalf("core was called %d times for invalid subject", calls)
+ }
+}
+
+func TestRenderBatchHonoursConfiguredLimit(t *testing.T) {
+ h, err := New(Config{ListenAddress: ":0", CoreInternalURL: "http://127.0.0.1:9", DisclosureBaseURL: "https://public.example", GeneratorURL: "https://public.example", PublicName: "Bulk", MaxURLs: 1, Workers: 1, RequestTimeout: time.Second}, testLogger())
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := httptest.NewRequest(http.MethodPost, "/api/render-batch", strings.NewReader(`{"template":"extent=partial","subjects":["https://example.org/a","https://example.org/b"]}`))
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+}
diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go
index 30493bd..f5f448c 100644
--- a/internal/i18n/catalog.go
+++ b/internal/i18n/catalog.go
@@ -38,7 +38,7 @@ var catalogs = map[string]Locale{
"field_activities": "Tätigkeiten", "activities_help": "Kommagetrennte Standardwerte, zum Beispiel research, summarisation oder translation.",
"review_help": "Nur den tatsächlich durchgeführten Prüfprozess angeben. Eine formale Prüfung (z. B. Rechtschreibung) ist keine substanzielle menschliche Prüfung oder redaktionelle Kontrolle im Sinne von Art. 50 Abs. 4 AI Act.",
"field_subject": "URL des gekennzeichneten Inhalts", "legal_context_legend": "Rechtlicher Kontext (Selbsteinordnung)", "legal_context_help": "Diese Angaben helfen bei der Art.-50-Einordnung. Sie sind keine automatische Rechtsentscheidung. Mehrere Kategorien können zutreffen.", "legal_deepfake": "Deepfake / realitätsähnliche KI-Manipulation", "legal_deepfake_help": "Nur auswählen, wenn Bild, Audio oder Video bestehenden oder plausibel existierenden Personen, Objekten, Orten, Entitäten oder Ereignissen ähnelt und fälschlich authentisch oder wahr erscheinen kann.", "legal_public_interest": "KI-generierter oder manipulierter Text zu einer Angelegenheit von öffentlichem Interesse", "legal_public_interest_help": "Zum Beispiel Politik, öffentliche Verwaltung, Grundrechte, Sicherheit, Gesundheit, Umwelt, Verbrauchersicherheit oder relevante wirtschaftliche, wissenschaftliche oder kulturelle Entwicklungen.", "legal_creative": "Evident künstlerisch / kreativ / satirisch / fiktional", "legal_creative_help": "Bei Deepfakes in solchen Werken kann die Offenlegung in einer angemessenen Form erfolgen, die Darstellung oder Genuss des Werks nicht beeinträchtigt.", "legal_other_voluntary": "Andere / freiwillige Transparenz", "legal_assessment_heading": "Vorsichtige Art.-50-Einschätzung", "legal_assessment_none": "Für die angegebenen Inhalte wurde keine inhaltsspezifische Offenlegungskategorie aus Art. 50 Abs. 4 erkannt. Das ist keine Aussage dazu, ob andere Pflichten aus Art. 50 oder sonstigem Recht greifen; die Kennzeichnung wird hier als freiwillige Transparenz behandelt.", "legal_assessment_voluntary": "Die Angaben sprechen derzeit für eine freiwillige Transparenzkennzeichnung; daraus folgt keine Aussage, dass sonstige AI-Act-Pflichten nicht gelten.", "legal_assessment_deepfake": "Auf Grundlage der Selbsteinordnung als Deepfake spricht vieles dafür, dass eine klare und unterscheidbare Offenlegung spätestens bei der ersten Exposition erforderlich ist.", "legal_assessment_deepfake_creative": "Der Inhalt ist als Deepfake und zugleich als evident künstlerisch, kreativ, satirisch oder fiktional eingeordnet. Eine Offenlegung bleibt grundsätzlich relevant, kann aber in angemessener Weise erfolgen, die Darstellung oder Genuss des Werks nicht beeinträchtigt.", "legal_assessment_public_required": "Auf Grundlage der Angaben spricht vieles dafür, dass der KI-generierte oder manipulierte Text zu einer Angelegenheit von öffentlichem Interesse klar offengelegt werden sollte. Eine ausreichende substanzielle menschliche Prüfung/redaktionelle Kontrolle zusammen mit ausdrücklich übernommener redaktioneller Verantwortung ist nicht vollständig dokumentiert.", "legal_assessment_public_exemption": "Die Angaben dokumentieren eine substanzielle menschliche Prüfung oder redaktionelle Kontrolle sowie eine ausdrücklich benannte redaktionelle Verantwortung. Daher kann die Ausnahme für bestimmte Texte von öffentlichem Interesse nach Art. 50 Abs. 4 in Betracht kommen. Eine freiwillige Transparenzkennzeichnung bleibt möglich.", "legal_assessment_inconsistent": "Die gewählte rechtliche Kategorie passt nicht vollständig zu den strukturierten Inhaltsangaben. Bitte prüfe KI-Anteil und Inhaltsbestandteile.", "badge_legal_disclosure": "KI-generierter / manipulierter Inhalt", "badge_public_interest": "KI-generierte / bearbeitete Inhalte", "fact_legal_context": "Rechtlicher Kontext (Selbsteinordnung)", "field_responsibility_role": "Art der redaktionellen Verantwortung", "responsibility_none": "Nicht angegeben", "responsibility_publisher": "Veröffentlichende Person/Organisation übernimmt die redaktionelle Verantwortung", "responsibility_other": "Andere verantwortliche Stelle", "field_responsible": "Redaktionell verantwortliche Person/Organisation", "responsible_placeholder": "Name oder Organisation", "field_responsible_url": "Nachweis/Impressum zur redaktionellen Verantwortung", "field_language": "Ausgabesprache", "field_theme": "Darstellung", "theme_mono": "Monochrom", "theme_color": "Farbig",
- "preview": "Vorschau", "copy_html": "HTML kopieren", "copied": "Kopiert",
+ "preview": "Vorschau", "copy_html": "HTML kopieren", "copied": "Kopiert", "open_bulk": "Als Bulk-Vorlage öffnen",
"pro_eyebrow": "Pro-Anpassung", "pro_title": "Eigene Texte und Badge-Designs", "pro_enabled": "Diese Instanz besitzt eine gültige Pro-Lizenz. Individuelle Texte und Farben können verwendet werden.",
"pro_locked": "Individuelle Titel, Beschreibungstexte, Badge-Beschriftungen und Farben sind in der Pro-Ausgabe verfügbar.",
"custom_title": "Eigener Erklärungstitel", "custom_description": "Eigene Beschreibung", "custom_badge_label": "Eigene Badge-Beschriftung links", "custom_badge_message": "Eigene Badge-Beschriftung rechts",
@@ -75,7 +75,7 @@ var catalogs = map[string]Locale{
"generator_eyebrow": "Generator", "generator_title": "Create an embed", "generator_intro": "Choose a template or record AI use systematically by content component. The preview, embed code and machine-readable declaration are generated immediately.",
"field_preset": "Preset", "option_custom": "Custom structure", "field_component": "Component", "field_extent": "AI contribution", "field_review": "Human review", "field_activities": "Activities",
"activities_help": "Comma-separated standard values such as research, summarisation or translation.", "review_help": "Record only the review process that actually took place. A merely formal check (for example spelling or grammar) is not substantive human review or editorial control for the purposes of Article 50(4) AI Act.", "field_subject": "URL of the labelled content", "field_responsibility_role": "Editorial responsibility type", "responsibility_none": "Not specified", "responsibility_publisher": "Publishing person/organisation assumes editorial responsibility", "responsibility_other": "Other responsible party", "field_responsible": "Editorially responsible person/organisation (optional)", "responsible_placeholder": "Name or organisation", "field_responsible_url": "Evidence/imprint for editorial responsibility (optional)", "field_language": "Output language", "field_theme": "Appearance", "theme_mono": "Monochrome", "theme_color": "Colour",
- "preview": "Preview", "copy_html": "Copy HTML", "copied": "Copied", "pro_eyebrow": "Pro customisation", "pro_title": "Custom copy and badge designs",
+ "preview": "Preview", "copy_html": "Copy HTML", "copied": "Copied", "open_bulk": "Open as bulk template", "pro_eyebrow": "Pro customisation", "pro_title": "Custom copy and badge designs",
"pro_enabled": "This instance has a valid Pro licence. Custom copy and colours are available.", "pro_locked": "Custom titles, descriptions, badge labels and colours are available in the Pro edition.",
"custom_title": "Custom declaration title", "custom_description": "Custom description", "custom_badge_label": "Custom left badge label", "custom_badge_message": "Custom right badge label", "custom_left_color": "Left colour", "custom_right_color": "Right colour", "pro_required": "Pro licence required",
"api_badge_title": "Badge endpoint", "api_badge_desc": "Standard badges are generated from presets and structured parameters. Pro adds custom copy and colours.", "api_manifest_title": "Manifest", "api_manifest_desc": "The JSON-LD manifest can be linked, validated or included in build pipelines.",
diff --git a/internal/marketing/content.go b/internal/marketing/content.go
index 4050bbb..507ab1e 100644
--- a/internal/marketing/content.go
+++ b/internal/marketing/content.go
@@ -183,13 +183,13 @@ func Build(lang, productName, baseURL, salesURL, contactURL string, prices Price
# Adjust BASE_URL and PUBLIC_NAME in .env
docker compose up -d --build
curl -fsS http://localhost:8080/readyz`},
- {ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.7.0 .
+ {ID: "docker", Title: copy.InstallTitles[1], Summary: copy.InstallSummaries[1], Code: `docker build -t ai-disclosure-standard:1.8.0 .
docker run -d --name ai-disclosure \
-p 8080:8080 \
-e BASE_URL=https://ai.example.org \
-e PUBLIC_NAME="AI Usage Disclosure" \
--read-only --tmpfs /tmp \
- ai-disclosure-standard:1.7.0`},
+ ai-disclosure-standard:1.8.0`},
{ID: "kubernetes", Title: copy.InstallTitles[2], Summary: copy.InstallSummaries[2], Code: `# Image, Domain und TLS-Secret in deploy/kubernetes.yaml ersetzen
kubectl create secret generic ai-disclosure-license \
--from-literal=token='...'
diff --git a/openapi.yaml b/openapi.yaml
index 7829406..80ffc66 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: AI Usage Disclosure API
- version: 1.7.0
+ version: 1.8.0
description: Stateless multilingual badge, declaration and validation API with optional licensed presentation capabilities.
servers:
- url: https://ai.example.org
@@ -129,6 +129,59 @@ paths:
application/ld+json:
schema: {$ref: './schema/declaration.schema.json'}
"403": {$ref: '#/components/responses/ProRequired'}
+ /v1/render:
+ get:
+ summary: Render reusable embedding artifacts from declaration query parameters
+ description: Returns HTML, Markdown and the parsed JSON-LD declaration from the same core logic used by the normal generator. The optional bulk container calls this endpoint for each subject URL.
+ parameters:
+ - {$ref: '#/components/parameters/preset'}
+ - {$ref: '#/components/parameters/extent'}
+ - {$ref: '#/components/parameters/language'}
+ - {$ref: '#/components/parameters/component'}
+ - {$ref: '#/components/parameters/activities'}
+ - {$ref: '#/components/parameters/review'}
+ - {name: subject, in: query, schema: {type: string, format: uri}}
+ - {$ref: '#/components/parameters/legalContext'}
+ - {$ref: '#/components/parameters/legalRole'}
+ - {$ref: '#/components/parameters/useContext'}
+ - {$ref: '#/components/parameters/outputDate'}
+ - {$ref: '#/components/parameters/deepfakeAssessment'}
+ - {$ref: '#/components/parameters/publicInterestAssessment'}
+ - {$ref: '#/components/parameters/creativeWorkAssessment'}
+ - {$ref: '#/components/parameters/lawEnforcementAuthorization'}
+ - {$ref: '#/components/parameters/author'}
+ - {$ref: '#/components/parameters/authorUrl'}
+ - {$ref: '#/components/parameters/responsibleRole'}
+ - {$ref: '#/components/parameters/responsible'}
+ - {$ref: '#/components/parameters/responsibleUrl'}
+ - {$ref: '#/components/parameters/complaintName'}
+ - {$ref: '#/components/parameters/complaintEmail'}
+ - {$ref: '#/components/parameters/complaintUrl'}
+ - {$ref: '#/components/parameters/assurance'}
+ - {$ref: '#/components/parameters/customTitle'}
+ - {$ref: '#/components/parameters/customDescription'}
+ - {$ref: '#/components/parameters/badgeLabel'}
+ - {$ref: '#/components/parameters/badgeMessage'}
+ - {$ref: '#/components/parameters/leftColor'}
+ - {$ref: '#/components/parameters/rightColor'}
+ - {$ref: '#/components/parameters/theme'}
+ responses:
+ "200":
+ description: Rendered artifacts
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [declarationUrl, badgeUrl, manifestUrl, html, markdown, jsonLd]
+ properties:
+ subject: {type: string, format: uri}
+ declarationUrl: {type: string, format: uri}
+ badgeUrl: {type: string, format: uri}
+ manifestUrl: {type: string, format: uri}
+ html: {type: string}
+ markdown: {type: string}
+ jsonLd: {$ref: './schema/declaration.schema.json'}
+ "403": {$ref: '#/components/responses/ProRequired'}
/v1/validate:
post:
summary: Validate an AI usage declaration
diff --git a/web/static/app.js b/web/static/app.js
index 46ca6c5..f36ce27 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -431,6 +431,17 @@
? `[](${declarationURL}) **${assessment.badge}**`
: `[](${declarationURL})`;
$('json-code').value = manifestURL;
+ const bulkLink = $('bulk-template-link');
+ if (bulkLink) {
+ bulkLink.hidden = !cfg.bulkURL;
+ if (cfg.bulkURL) {
+ const bulkParams = new URLSearchParams(params);
+ bulkParams.delete('subject');
+ bulkParams.delete('link');
+ const payload = {version: 1, query: bulkParams.toString(), theme: $('theme').value};
+ bulkLink.href = `${cfg.bulkURL}/#template=${encodeURIComponent(JSON.stringify(payload))}`;
+ }
+ }
}
function enforceLegalExclusivity(changedId) {
diff --git a/web/templates/background.html b/web/templates/background.html
index b31ad3f..0521f10 100644
--- a/web/templates/background.html
+++ b/web/templates/background.html
@@ -158,7 +158,7 @@
-
+