Files
neural-hunt/internal/server/artifact_references.go
jbergner 185ccf1101
All checks were successful
release-tag / release-image (push) Successful in 2m10s
RC-6
2026-08-10 19:44:17 +02:00

259 lines
8.2 KiB
Go

package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"neuralhunt/internal/artifact"
wsx "neuralhunt/internal/ws"
"github.com/go-chi/chi/v5"
)
const maxStyleReferenceBytes = 12 << 20
func (s *Server) adminCreateCharacterAnchor(w http.ResponseWriter, r *http.Request) {
if s.artifactWorker == nil {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact worker unavailable"})
return
}
if err := s.artifactWorker.CreateCharacterAnchor(r.Context()); err != nil {
if errors.Is(err, artifact.ErrCharacterAnchorExists) {
jsonOut(w, http.StatusConflict, map[string]string{"error": "character anchor already exists"})
return
}
if errors.Is(err, artifact.ErrOpenAIBudgetExceeded) {
jsonOut(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusCreated, map[string]any{
"ok": true,
"url": "/api/admin/artifact/character-anchor?ts=" + fmt.Sprint(time.Now().UnixMilli()),
})
}
func (s *Server) adminCharacterAnchorFile(w http.ResponseWriter, r *http.Request) {
path := filepath.Join(s.artifactDir, "_collection", "character_anchor.png")
b, err := os.ReadFile(path)
if err != nil || len(b) <= 1024 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Content-Length", fmt.Sprint(len(b)))
_, _ = w.Write(b)
}
func normalizeUploadedStyle(raw []byte) (ext, contentType string, width, height int, err error) {
if len(raw) < 128 {
return "", "", 0, 0, errors.New("style reference is empty")
}
contentType = strings.Split(http.DetectContentType(raw), ";")[0]
switch contentType {
case "image/jpeg":
ext = ".jpg"
case "image/png":
ext = ".png"
default:
return "", "", 0, 0, fmt.Errorf("unsupported style image type %q; use JPEG or PNG", contentType)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return "", "", 0, 0, fmt.Errorf("decode style image: %w", err)
}
if cfg.Width < 128 || cfg.Height < 128 || cfg.Width > 8192 || cfg.Height > 8192 || int64(cfg.Width)*int64(cfg.Height) > 40_000_000 {
return "", "", 0, 0, fmt.Errorf("style image dimensions %dx%d are outside the allowed range", cfg.Width, cfg.Height)
}
return ext, contentType, cfg.Width, cfg.Height, nil
}
func writeStyleAsset(dir, name string, raw []byte) error {
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
target := filepath.Join(dir, name)
if st, err := os.Stat(target); err == nil && st.Size() == int64(len(raw)) {
// The filename is a SHA-256 of the file contents, so an existing asset
// with the same name is the same immutable reference. This also avoids
// Windows rename-over-existing behavior.
return nil
}
tmp, err := os.CreateTemp(dir, ".style-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0o640); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(raw); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, target)
}
func (s *Server) adminTaskStyleReferencePut(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
r.Body = http.MaxBytesReader(w, r.Body, maxStyleReferenceBytes+1<<20)
if err := r.ParseMultipartForm(maxStyleReferenceBytes); err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload: " + err.Error()})
return
}
f, _, err := r.FormFile("file")
if err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "multipart field 'file' is required"})
return
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, maxStyleReferenceBytes+1))
if err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "read upload: " + err.Error()})
return
}
if len(raw) > maxStyleReferenceBytes {
jsonOut(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "style reference exceeds 12 MiB"})
return
}
ext, contentType, width, height, err := normalizeUploadedStyle(raw)
if err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h := sha256.Sum256(raw)
name := hex.EncodeToString(h[:]) + ext
if err := writeStyleAsset(filepath.Join(s.artifactDir, "_styles"), name, raw); err != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": "store style reference: " + err.Error()})
return
}
if err := s.store.SetTaskStyleReference(r.Context(), id, name); err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "style_reference"}})
jsonOut(w, http.StatusOK, map[string]any{
"ok": true,
"name": name,
"sha256": hex.EncodeToString(h[:]),
"content_type": contentType,
"width": width,
"height": height,
"url": "/api/admin/tasks/" + id + "/style-reference?ts=" + fmt.Sprint(time.Now().UnixMilli()),
})
}
func (s *Server) adminTaskStyleReferenceDelete(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetTaskStyleReference(r.Context(), id, ""); err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "style_reference"}})
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
}
func serveTaskStyleReference(w http.ResponseWriter, r *http.Request, ref artifact.TaskStyleReference, cache string) {
if len(ref.Bytes) == 0 {
http.NotFound(w, r)
return
}
ct := ref.ContentType
if ct == "" {
ct = http.DetectContentType(ref.Bytes)
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", cache)
w.Header().Set("ETag", `"`+ref.SHA256+`"`)
w.Header().Set("Content-Length", fmt.Sprint(len(ref.Bytes)))
_, _ = w.Write(ref.Bytes)
}
func (s *Server) adminTaskStyleReferenceFile(w http.ResponseWriter, r *http.Request) {
if s.artifactWorker == nil {
http.NotFound(w, r)
return
}
ref, err := s.artifactWorker.TaskStyleReference(r.Context(), chi.URLParam(r, "id"))
if err != nil {
http.NotFound(w, r)
return
}
serveTaskStyleReference(w, r, ref, "private, no-store")
}
func (s *Server) publicTaskStyleReference(w http.ResponseWriter, r *http.Request) {
if s.artifactWorker == nil {
http.NotFound(w, r)
return
}
id := chi.URLParam(r, "id")
var status string
if err := s.store.DB.QueryRowContext(r.Context(), `SELECT status FROM tasks WHERE id=?`, id).Scan(&status); err != nil || status != "active" {
http.NotFound(w, r)
return
}
ref, err := s.artifactWorker.TaskStyleReference(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
serveTaskStyleReference(w, r, ref, "public, max-age=300")
}
func (s *Server) adminCreatePipelineTestCard(w http.ResponseWriter, r *http.Request) {
if s.artifactWorker == nil {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact worker unavailable"})
return
}
id := chi.URLParam(r, "id")
path, err := s.artifactWorker.CreatePipelineTestCard(r.Context(), id)
if err != nil {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = path
jsonOut(w, http.StatusCreated, map[string]any{"ok": true, "url": "/api/admin/tasks/" + id + "/pipeline-test/card?ts=" + fmt.Sprint(time.Now().UnixMilli()), "api_calls": 0, "cost_usd": 0})
}
func (s *Server) adminPipelineTestCardFile(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if filepath.Base(id) != id || strings.ContainsAny(id, `/\\`) {
http.NotFound(w, r)
return
}
path := filepath.Join(s.artifactDir, "_test", id, "image.svg")
b, err := os.ReadFile(path)
if err != nil || len(b) < 128 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "private, no-store")
_, _ = w.Write(b)
}