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) }