All checks were successful
release-tag / release-image (push) Successful in 2m10s
259 lines
8.2 KiB
Go
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)
|
|
}
|