186 lines
6.5 KiB
Go
186 lines
6.5 KiB
Go
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[:])
|
|
}
|