@@ -0,0 +1,254 @@
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user