package app import ( "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/url" "strings" "time" "github.com/b1tsblog/ai-disclosure-standard/internal/declaration" "github.com/b1tsblog/ai-disclosure-standard/internal/i18n" ) type bulkRequest struct { Items []bulkItem `json:"items"` } type bulkItem struct { ID string `json:"id,omitempty"` Parameters map[string]string `json:"parameters"` } type bulkResult struct { ID string `json:"id,omitempty"` Valid bool `json:"valid"` Declaration *declaration.Declaration `json:"declaration,omitempty"` Assessment *declaration.Article50Assessment `json:"article50Assessment,omitempty"` DeclarationURL string `json:"declarationUrl,omitempty"` ManifestURL string `json:"manifestUrl,omitempty"` BadgeURL string `json:"badgeUrl,omitempty"` Error *bulkError `json:"error,omitempty"` } type bulkError struct { Code string `json:"code"` Detail string `json:"detail"` } type declarationBundle struct { Product string `json:"product"` ProductVersion string `json:"productVersion"` GeneratedAt string `json:"generatedAt"` Declaration declaration.Declaration `json:"declaration"` DeclarationDigest string `json:"declarationDigest"` Assessment declaration.Article50Assessment `json:"article50Assessment"` DeclarationURL string `json:"declarationUrl"` ManifestURL string `json:"manifestUrl"` BadgeURL string `json:"badgeUrl"` } func (s *Server) handleBulkDeclarations(w http.ResponseWriter, r *http.Request) { s.metrics.bulkRequests.Add(1) if !s.bulkAuthorized(r) { s.metrics.bulkFailures.Add(1) w.Header().Set("WWW-Authenticate", `Bearer realm="bulk-api"`) s.problem(w, http.StatusUnauthorized, "bulk_authentication_required", "A valid bulk API key is required.") return } if !s.licenses.Has(FeatureBulkAPI) { s.metrics.bulkFailures.Add(1) s.problem(w, http.StatusForbidden, "licensed_feature_required", "The bulk API requires the licensed capability bulk_api.") return } limit := s.cfg.BulkMaxItems if licensedLimit, ok := s.licenses.Limit("bulk_items"); ok && licensedLimit > 0 && int64(limit) > licensedLimit { limit = int(licensedLimit) } body := http.MaxBytesReader(w, r.Body, s.cfg.BulkMaxBodyBytes) defer body.Close() dec := json.NewDecoder(body) dec.DisallowUnknownFields() var request bulkRequest if err := dec.Decode(&request); err != nil { s.metrics.bulkFailures.Add(1) s.problem(w, http.StatusBadRequest, "invalid_json", err.Error()) return } if err := ensureEOF(dec); err != nil { s.metrics.bulkFailures.Add(1) s.problem(w, http.StatusBadRequest, "invalid_json", err.Error()) return } if len(request.Items) == 0 { s.metrics.bulkFailures.Add(1) s.problem(w, http.StatusBadRequest, "empty_bulk_request", "At least one item is required.") return } if len(request.Items) > limit { s.metrics.bulkFailures.Add(1) s.problem(w, http.StatusRequestEntityTooLarge, "bulk_limit_exceeded", "The request exceeds the configured or licensed bulk item limit.") return } s.metrics.bulkItems.Add(uint64(len(request.Items))) results := make([]bulkResult, 0, len(request.Items)) for _, item := range request.Items { q := url.Values{} for key, value := range item.Parameters { q.Set(key, value) } if q.Get("lang") == "" { q.Set("lang", s.cfg.DefaultLanguage) } d, err := s.declarationFromQuery(q) if err != nil { results = append(results, bulkResult{ID: strings.TrimSpace(item.ID), Valid: false, Error: &bulkError{Code: "invalid_declaration", Detail: err.Error()}}) continue } assessment := declaration.AssessArticle50(d) declarationURL, manifestURL, badgeURL := s.generatedURLs(q) results = append(results, bulkResult{ ID: strings.TrimSpace(item.ID), Valid: true, Declaration: &d, Assessment: &assessment, DeclarationURL: declarationURL, ManifestURL: manifestURL, BadgeURL: badgeURL, }) } s.writeJSON(w, http.StatusOK, map[string]any{ "product": ProductID, "productVersion": ProductVersion, "count": len(results), "items": results, }) } func (s *Server) handleArticle50Assessment(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) assessment := declaration.AssessArticle50(d) view := article50AssessmentView(assessment, locale) s.writeJSON(w, http.StatusOK, map[string]any{ "assessment": assessment, "title": view.Title, "summary": view.Summary, "findings": view.Details, "warnings": view.Warnings, "disclaimer": locale.Text["assessment_disclaimer"], }) } func (s *Server) handleDeclarationBundle(w http.ResponseWriter, r *http.Request) { if !s.licenses.Has(FeatureExportBundle) { s.problem(w, http.StatusForbidden, "licensed_feature_required", "Declaration bundles require the licensed capability export_bundle.") return } 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 } declarationURL, manifestURL, badgeURL := s.generatedURLs(q) bundle := declarationBundle{ Product: ProductID, ProductVersion: ProductVersion, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Declaration: d, DeclarationDigest: declarationDigest(d), Assessment: declaration.AssessArticle50(d), DeclarationURL: declarationURL, ManifestURL: manifestURL, BadgeURL: badgeURL, } w.Header().Set("Cache-Control", "no-store") s.writeJSON(w, http.StatusOK, bundle) } func (s *Server) generatedURLs(q url.Values) (string, string, string) { encoded := q.Encode() base := s.cfg.OutputBaseURL if base == "" { base = s.cfg.BaseURL } declarationURL := base + "/declaration?" + encoded manifestURL := base + "/v1/declaration.json?" + encoded badgeQ := cloneValues(q) badgeQ.Set("link", declarationURL) badgeURL := base + "/v1/badge.svg?" + badgeQ.Encode() return declarationURL, manifestURL, badgeURL } func declarationDigest(d declaration.Declaration) string { encoded, err := json.Marshal(d) if err != nil { return "" } digest := sha256.Sum256(encoded) return "sha256:" + hex.EncodeToString(digest[:]) }