Artifact and media metadata were previously reconstructed from job result refs and JSON sidecar files. This change regularizes metadata into SQLite, keeps filesystem blobs in place, and preserves backward compatibility via lazy fallback and backfill from existing job refs and JSON metadata. Constraint: Blob storage remains on the local filesystem in this phase Rejected: Migrate blobs into SQLite | larger scope and worse operational profile for current media sizes Rejected: Hard cutover without fallback | unsafe for historical data already on the test server Confidence: medium Scope-risk: moderate Directive: Treat SQLite as the metadata source of truth; JSON sidecars are compatibility fallback only Tested: go test ./...; deployed to test server 101.42.99.35; verified /v1/artifacts, /v1/artifacts/:id, signed media URL 200, unsigned content 401 Not-tested: Full historical backfill sweep over all existing artifact rows under production-sized data volume
2009 lines
55 KiB
Go
2009 lines
55 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Server struct {
|
|
store *store
|
|
mux *http.ServeMux
|
|
cfg Config
|
|
newapi *newAPIClient
|
|
}
|
|
|
|
func New() *Server {
|
|
svc, err := NewWithConfig(ConfigFromEnv())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return svc
|
|
}
|
|
|
|
func NewWithConfig(cfg Config) (*Server, error) {
|
|
cfg = normalizeConfig(cfg)
|
|
store, err := newStore(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Server{
|
|
store: store,
|
|
mux: http.NewServeMux(),
|
|
cfg: cfg,
|
|
newapi: newNewAPIClient(cfg),
|
|
}
|
|
s.routes()
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.mux
|
|
}
|
|
|
|
func (s *Server) routes() {
|
|
s.mux.HandleFunc("/health", s.handleHealth)
|
|
s.mux.HandleFunc("/v1/auth/login", s.handleAuthLogin)
|
|
s.mux.HandleFunc("/v1/auth/me", s.handleAuthMe)
|
|
s.mux.HandleFunc("/v1/auth/logout", s.handleAuthLogout)
|
|
s.mux.HandleFunc("/v1/auth/token/rotate", s.handleAuthTokenRotate)
|
|
s.mux.HandleFunc("/v1/skills", s.handleSkills)
|
|
s.mux.HandleFunc("/v1/skills/", s.handleSkill)
|
|
s.mux.HandleFunc("/v1/jobs", s.handleJobs)
|
|
s.mux.HandleFunc("/v1/jobs/", s.handleJob)
|
|
s.mux.HandleFunc("/v1/media/upload", s.handleMediaUpload)
|
|
s.mux.HandleFunc("/v1/media/", s.handleMedia)
|
|
s.mux.HandleFunc("/v1/artifacts", s.handleArtifacts)
|
|
s.mux.HandleFunc("/v1/artifacts/upload", s.handleArtifactUpload)
|
|
s.mux.HandleFunc("/v1/artifacts/", s.handleArtifact)
|
|
s.mux.HandleFunc("/v1/budget", s.handleBudget)
|
|
s.mux.HandleFunc("/v1/budget/usage", s.handleBudgetUsage)
|
|
s.mux.HandleFunc("/v1/budget/limits", s.handleBudgetLimits)
|
|
s.mux.HandleFunc("/v1/projects", s.handleProjects)
|
|
s.mux.HandleFunc("/v1/projects/", s.handleProject)
|
|
s.mux.HandleFunc("/v1/models", s.handleModels)
|
|
s.mux.HandleFunc("/v1/models/routes", s.handleModelRoutes)
|
|
s.mux.HandleFunc("/v1/models/routes/overrides", s.handleModelRouteOverrides)
|
|
s.mux.HandleFunc("/v1/models/routes/overrides/unset", s.handleModelRouteOverrideUnset)
|
|
s.mux.HandleFunc("/v1/models/infer", s.handleModelsInfer)
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"service": "popiart-server-dev",
|
|
"version": "0.2.0",
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
var req struct {
|
|
Key string `json:"key"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
req.Key = strings.TrimSpace(req.Key)
|
|
if req.Key == "" {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key is required", nil)
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusInternalServerError, "SERVER_ERROR", "PopiNewAPI base URL is not configured", nil)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
if err := s.newapi.verifyKey(ctx, req.Key); err != nil {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key invalid or expired", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
key, u, ok, err := s.store.createSession(req.Key)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to create session", err)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key invalid or expired", nil)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"key": key,
|
|
"token": key,
|
|
"user": u,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, authSessionView{
|
|
User: current.User,
|
|
SessionKey: current.Token,
|
|
UpstreamKeyMasked: maskSecret(current.UpstreamKey),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
token, ok := bearerToken(r)
|
|
if ok {
|
|
_ = s.store.deleteSession(token)
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{"logged_out": true})
|
|
}
|
|
|
|
func (s *Server) handleAuthTokenRotate(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
token, ok := bearerToken(r)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing bearer key", nil)
|
|
return
|
|
}
|
|
newToken, u, exists, err := s.store.rotateSession(token)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to rotate session", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key invalid or expired", nil)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"key": newToken,
|
|
"token": newToken,
|
|
"user": u,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
tag := r.URL.Query().Get("tag")
|
|
search := r.URL.Query().Get("search")
|
|
limit := intQuery(r, "limit", 50)
|
|
offset := intQuery(r, "offset", 0)
|
|
items, total := s.store.listSkills(tag, search, limit, offset)
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSkill(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/skills/")
|
|
if path == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
if strings.HasSuffix(path, "/schema") {
|
|
id := strings.TrimSuffix(strings.TrimSuffix(path, "/schema"), "/")
|
|
item, exists := s.store.getSkill(id)
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"input_schema": item.InputSchema,
|
|
"output_schema": item.OutputSchema,
|
|
})
|
|
return
|
|
}
|
|
|
|
item, exists := s.store.getSkill(strings.Trim(path, "/"))
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) handleJobs(w http.ResponseWriter, r *http.Request) {
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
var req struct {
|
|
SkillID string `json:"skill_id"`
|
|
Input map[string]any `json:"input"`
|
|
ProjectID string `json:"project_id"`
|
|
Priority string `json:"priority"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.SkillID == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "skill_id is required", nil)
|
|
return
|
|
}
|
|
item, exists := s.store.getSkill(req.SkillID)
|
|
if !exists {
|
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "skill not found", nil)
|
|
return
|
|
}
|
|
routeKey := normalizeRouteKey(item.RouteKey)
|
|
modelID := s.resolveModelID(routeKey, req.ProjectID)
|
|
record, statusCode, err := s.store.createJob(
|
|
req.SkillID,
|
|
routeKey,
|
|
modelID,
|
|
routeExecMode(routeKey),
|
|
req.Input,
|
|
req.ProjectID,
|
|
defaultString(req.Priority, "normal"),
|
|
req.IdempotencyKey,
|
|
current,
|
|
)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to create job", err)
|
|
return
|
|
}
|
|
if statusCode == http.StatusAccepted {
|
|
go s.dispatchJob(record)
|
|
}
|
|
writeData(w, statusCode, map[string]any{
|
|
"job_id": record.JobID,
|
|
"status": record.Status,
|
|
"created_at": record.CreatedAt,
|
|
})
|
|
case http.MethodGet:
|
|
status := r.URL.Query().Get("status")
|
|
skillID := r.URL.Query().Get("skill_id")
|
|
projectID := r.URL.Query().Get("project_id")
|
|
limit := intQuery(r, "limit", 20)
|
|
offset := intQuery(r, "offset", 0)
|
|
items, total, err := s.store.listJobs(current.User.ID, status, skillID, projectID, limit, offset)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to list jobs", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
default:
|
|
methodNotAllowed(w)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleJob(w http.ResponseWriter, r *http.Request) {
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/jobs/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
jobID := parts[0]
|
|
|
|
if len(parts) == 1 && r.Method == http.MethodGet {
|
|
record, exists, err := s.store.getJob(current.User.ID, jobID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load job", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, record)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "cancel" && r.Method == http.MethodPost {
|
|
record, exists, conflict, err := s.store.cancelJob(current.User.ID, jobID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to cancel job", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if conflict {
|
|
writeError(w, http.StatusConflict, "CONFLICT", "job already in terminal state", nil)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, record)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "logs" && r.Method == http.MethodGet {
|
|
logs, exists, err := s.store.jobLogs(current.User.ID, jobID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load job logs", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
flusher, _ := w.(http.Flusher)
|
|
for _, item := range logs {
|
|
fmt.Fprintf(w, "data: %s\n\n", mustJSON(item))
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
time.Sleep(150 * time.Millisecond)
|
|
}
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, logs)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "artifacts" && r.Method == http.MethodGet {
|
|
items, exists, err := s.store.artifactsForJob(current.User.ID, jobID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to list job artifacts", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{"items": items})
|
|
return
|
|
}
|
|
|
|
notFound(w)
|
|
}
|
|
|
|
func (s *Server) handleArtifacts(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
projectID := strings.TrimSpace(r.URL.Query().Get("project_id"))
|
|
jobID := strings.TrimSpace(r.URL.Query().Get("job_id"))
|
|
limit := intQuery(r, "limit", 20)
|
|
offset := intQuery(r, "offset", 0)
|
|
|
|
items, total, err := s.store.listArtifacts(current.User.ID, projectID, jobID, limit, offset)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to list artifacts", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"items": artifactViews(s.cfg, items, time.Now().UTC()),
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 20<<20)
|
|
if err := r.ParseMultipartForm(20 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "failed to parse multipart form", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "file is required", nil)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "failed to read upload file", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if len(content) == 0 {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "uploaded file is empty", nil)
|
|
return
|
|
}
|
|
|
|
var metadata any
|
|
metadataJSON := strings.TrimSpace(r.FormValue("metadata_json"))
|
|
if metadataJSON != "" {
|
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "metadata_json must be valid JSON", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
filename := strings.TrimSpace(r.FormValue("filename"))
|
|
if filename == "" {
|
|
filename = strings.TrimSpace(header.Filename)
|
|
}
|
|
contentType := strings.TrimSpace(r.FormValue("content_type"))
|
|
if contentType == "" {
|
|
contentType = strings.TrimSpace(header.Header.Get("Content-Type"))
|
|
}
|
|
if contentType == "" || contentType == "application/octet-stream" {
|
|
contentType = http.DetectContentType(content)
|
|
}
|
|
if filename == "" {
|
|
filename = "artifact" + extensionFromContentType(contentType)
|
|
}
|
|
filename = sanitizeFilename(filename)
|
|
visibility := strings.TrimSpace(r.FormValue("visibility"))
|
|
|
|
input := map[string]any{
|
|
"filename": filename,
|
|
"content_type": contentType,
|
|
"size_bytes": len(content),
|
|
"role": strings.TrimSpace(r.FormValue("role")),
|
|
}
|
|
projectID := strings.TrimSpace(r.FormValue("project_id"))
|
|
if projectID != "" {
|
|
input["project_id"] = projectID
|
|
}
|
|
if metadata != nil {
|
|
input["metadata"] = metadata
|
|
}
|
|
|
|
record, _, err := s.store.createJob(
|
|
"popiskill-artifact-upload-local-v1",
|
|
"artifact.upload",
|
|
"",
|
|
"local_upload",
|
|
input,
|
|
projectID,
|
|
"normal",
|
|
"",
|
|
current,
|
|
)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to create upload job", err)
|
|
return
|
|
}
|
|
|
|
refMedia, err := s.store.persistMediaContent(
|
|
current.User.ID,
|
|
projectID,
|
|
buildArtifactID(record.JobID, 0),
|
|
filename,
|
|
contentType,
|
|
visibility,
|
|
content,
|
|
"",
|
|
)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to persist upload media", err)
|
|
return
|
|
}
|
|
|
|
ref := resultRef{
|
|
Kind: "local_path",
|
|
URL: refMedia.URL,
|
|
LocalPath: refMedia.LocalPath,
|
|
MediaID: refMedia.ID,
|
|
Filename: refMedia.Filename,
|
|
ContentType: refMedia.ContentType,
|
|
SizeBytes: refMedia.SizeBytes,
|
|
Visibility: refMedia.Visibility,
|
|
SHA256: refMedia.SHA256,
|
|
StorageStatus: "ready",
|
|
}
|
|
if err := s.store.completeJobWithResults(record.JobID, []resultRef{ref}, nil); err != nil {
|
|
writeInternalError(w, "failed to persist upload artifact", err)
|
|
return
|
|
}
|
|
|
|
item, _, exists, err := s.store.artifactRef(current.User.ID, buildArtifactID(record.JobID, 0))
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load uploaded artifact", err)
|
|
return
|
|
}
|
|
if !exists || item == nil {
|
|
writeInternalError(w, "uploaded artifact missing after persistence", errors.New("artifact not found"))
|
|
return
|
|
}
|
|
writeData(w, http.StatusCreated, artifactView(s.cfg, *item, time.Now().UTC()))
|
|
}
|
|
|
|
func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "failed to parse multipart form", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "file is required", nil)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "failed to read upload file", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if len(content) == 0 {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "uploaded file is empty", nil)
|
|
return
|
|
}
|
|
|
|
metadataJSON := strings.TrimSpace(r.FormValue("metadata_json"))
|
|
if metadataJSON != "" {
|
|
var metadata any
|
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "metadata_json must be valid JSON", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
filename := strings.TrimSpace(r.FormValue("filename"))
|
|
if filename == "" {
|
|
filename = strings.TrimSpace(header.Filename)
|
|
}
|
|
contentType := strings.TrimSpace(r.FormValue("content_type"))
|
|
if contentType == "" {
|
|
contentType = strings.TrimSpace(header.Header.Get("Content-Type"))
|
|
}
|
|
if contentType == "" || contentType == "application/octet-stream" {
|
|
contentType = http.DetectContentType(content)
|
|
}
|
|
if filename == "" {
|
|
filename = "media" + extensionFromContentType(contentType)
|
|
}
|
|
filename = sanitizeFilename(filename)
|
|
|
|
record, err := s.store.persistMediaContent(
|
|
current.User.ID,
|
|
strings.TrimSpace(r.FormValue("project_id")),
|
|
"",
|
|
filename,
|
|
contentType,
|
|
strings.TrimSpace(r.FormValue("visibility")),
|
|
content,
|
|
"",
|
|
)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to persist media", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusCreated, mediaView(s.cfg, record, time.Now().UTC()))
|
|
}
|
|
|
|
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/media/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
record, exists, err := s.store.getMedia(parts[0])
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load media", err)
|
|
return
|
|
}
|
|
if !exists || record == nil {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "content" && r.Method == http.MethodGet {
|
|
if !validateSignedMediaAccess(s.cfg, record.ID, r.URL.Query().Get("exp"), r.URL.Query().Get("sig"), time.Now().UTC()) {
|
|
current, ok, err := s.sessionFromRequest(r)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load session", err)
|
|
return
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing bearer key or signed media url", nil)
|
|
return
|
|
}
|
|
if current.User.ID != record.UserID {
|
|
notFound(w)
|
|
return
|
|
}
|
|
}
|
|
|
|
file, err := os.Open(record.LocalPath)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to read media content", err)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
w.Header().Set("Content-Type", defaultString(record.ContentType, "application/octet-stream"))
|
|
if record.SizeBytes > 0 {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(record.SizeBytes, 10))
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := io.Copy(w, file); err != nil {
|
|
log.Printf("popiartServer: streaming media %s failed: %v", record.ID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if current.User.ID != record.UserID {
|
|
notFound(w)
|
|
return
|
|
}
|
|
if len(parts) == 1 && r.Method == http.MethodGet {
|
|
writeData(w, http.StatusOK, mediaView(s.cfg, *record, time.Now().UTC()))
|
|
return
|
|
}
|
|
|
|
notFound(w)
|
|
}
|
|
|
|
func (s *Server) handleArtifact(w http.ResponseWriter, r *http.Request) {
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/artifacts/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
artifactID := parts[0]
|
|
item, ref, exists, err := s.store.artifactRef(current.User.ID, artifactID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load artifact", err)
|
|
return
|
|
}
|
|
if !exists {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 1 && r.Method == http.MethodGet {
|
|
writeData(w, http.StatusOK, artifactView(s.cfg, *item, time.Now().UTC()))
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "content" && r.Method == http.MethodGet {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
|
defer cancel()
|
|
contentType, sizeBytes, reader, err := s.newapi.openResultRef(ctx, current.UpstreamKey, ref)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to read artifact content", err)
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
w.Header().Set("Content-Type", defaultString(contentType, item.ContentType))
|
|
if sizeBytes > 0 {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(sizeBytes, 10))
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := io.Copy(w, reader); err != nil {
|
|
log.Printf("popiartServer: streaming artifact %s failed: %v", artifactID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
notFound(w)
|
|
}
|
|
|
|
func (s *Server) handleBudget(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"period": map[string]any{
|
|
"start": time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339),
|
|
"end": time.Now().UTC().Format(time.RFC3339),
|
|
},
|
|
"used": map[string]any{
|
|
"tokens": 12000,
|
|
"cost_usd": 4.62,
|
|
},
|
|
"limit": map[string]any{
|
|
"monthly_tokens": 1000000,
|
|
"monthly_cost_usd": 200,
|
|
},
|
|
"remaining": map[string]any{
|
|
"tokens": 988000,
|
|
"cost_usd": 195.38,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBudgetUsage(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"rows": []map[string]any{
|
|
{"dimension": "popiskill-image-text2image-basic-v1", "tokens_used": 3000, "cost_usd": 1.2, "job_count": 3},
|
|
{"dimension": "popiskill-video-image2video-basic-v1", "tokens_used": 9000, "cost_usd": 3.42, "job_count": 2},
|
|
},
|
|
"total": map[string]any{"tokens_used": 12000, "cost_usd": 4.62, "job_count": 5},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBudgetLimits(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"rate_limits": []map[string]any{
|
|
{"window": "1m", "limit": 60, "remaining": 59, "reset_at": time.Now().UTC().Add(time.Minute).Format(time.RFC3339)},
|
|
},
|
|
"quota": map[string]any{"monthly_tokens": 1000000, "monthly_cost_usd": 200},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"items": s.store.projects,
|
|
"total": len(s.store.projects),
|
|
"limit": intQuery(r, "limit", 20),
|
|
"offset": 0,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleProject(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/v1/projects/")
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
projectID := parts[0]
|
|
|
|
var found *project
|
|
for _, item := range s.store.projects {
|
|
if item.ID == projectID {
|
|
copyItem := item
|
|
found = ©Item
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
notFound(w)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 1 {
|
|
writeData(w, http.StatusOK, found)
|
|
return
|
|
}
|
|
|
|
if len(parts) == 2 && parts[1] == "context" {
|
|
routes, err := s.store.routesForProject(projectID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load project routes", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"project": found,
|
|
"budget": map[string]any{
|
|
"used": map[string]any{"tokens": 12000, "cost_usd": 4.62},
|
|
"limit": map[string]any{"monthly_tokens": 1000000, "monthly_cost_usd": 200},
|
|
"remaining": map[string]any{"tokens": 988000, "cost_usd": 195.38},
|
|
},
|
|
"route_overrides": routes,
|
|
"available_skills": s.store.skills,
|
|
})
|
|
return
|
|
}
|
|
|
|
notFound(w)
|
|
}
|
|
|
|
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/models" {
|
|
notFound(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
writeError(w, http.StatusServiceUnavailable, "MODEL_LIST_UNAVAILABLE", "PopiNewAPI is not configured for popiartServer", nil)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
items, err := s.newapi.listModels(ctx, current.UpstreamKey)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "MODEL_LIST_FAILED", "failed to load models from PopiNewAPI", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
modelType := r.URL.Query().Get("type")
|
|
provider := r.URL.Query().Get("provider")
|
|
filtered := make([]model, 0, len(items))
|
|
for _, item := range items {
|
|
if modelType != "" && item.Type != modelType {
|
|
continue
|
|
}
|
|
if provider != "" && item.Provider != provider {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{"items": filtered})
|
|
}
|
|
|
|
func (s *Server) handleModelRoutes(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID := r.URL.Query().Get("project_id")
|
|
routes, err := s.store.routesForProject(projectID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load route overrides", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"global": map[string]any{
|
|
"image": s.cfg.DefaultImageModel,
|
|
"video": s.cfg.DefaultVideoModel,
|
|
"audio": "",
|
|
},
|
|
"overrides": map[string]any{
|
|
projectID: routes,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleModelRouteOverrides(w http.ResponseWriter, r *http.Request) {
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
projectID := r.URL.Query().Get("project_id")
|
|
routes, err := s.store.routesForProject(projectID)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load route overrides", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"project_id": projectID,
|
|
"items": routes,
|
|
})
|
|
case http.MethodPost:
|
|
var req struct {
|
|
ProjectID string `json:"project_id"`
|
|
SkillType string `json:"skill_type"`
|
|
ModelID string `json:"model_id"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.ProjectID == "" || req.SkillType == "" || req.ModelID == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "project_id, skill_type, and model_id are required", nil)
|
|
return
|
|
}
|
|
routeKey := normalizeRouteKey(req.SkillType)
|
|
if err := s.store.setRouteOverride(req.ProjectID, routeKey, req.ModelID); err != nil {
|
|
writeInternalError(w, "failed to save route override", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"project_id": req.ProjectID,
|
|
"skill_type": req.SkillType,
|
|
"route_key": routeKey,
|
|
"model_id": req.ModelID,
|
|
})
|
|
default:
|
|
methodNotAllowed(w)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleModelRouteOverrideUnset(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
_, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
ProjectID string `json:"project_id"`
|
|
SkillType string `json:"skill_type"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.ProjectID == "" || req.SkillType == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "project_id and skill_type are required", nil)
|
|
return
|
|
}
|
|
routeKey := normalizeRouteKey(req.SkillType)
|
|
if err := s.store.unsetRouteOverride(req.ProjectID, routeKey); err != nil {
|
|
writeInternalError(w, "failed to delete route override", err)
|
|
return
|
|
}
|
|
writeData(w, http.StatusOK, map[string]any{
|
|
"project_id": req.ProjectID,
|
|
"skill_type": req.SkillType,
|
|
"route_key": routeKey,
|
|
"unset": true,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleModelsInfer(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
current, ok := s.authenticateSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
ModelID string `json:"model_id"`
|
|
Input map[string]any `json:"input"`
|
|
ProjectID string `json:"project_id"`
|
|
Priority string `json:"priority"`
|
|
IdempotencyKey string `json:"idempotency_key"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.ModelID == "" {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "model_id is required", nil)
|
|
return
|
|
}
|
|
routeKey := inferRouteKeyForModel(req.ModelID, req.Input)
|
|
record, statusCode, err := s.store.createJob(
|
|
"",
|
|
routeKey,
|
|
req.ModelID,
|
|
routeExecMode(routeKey),
|
|
req.Input,
|
|
req.ProjectID,
|
|
defaultString(req.Priority, "normal"),
|
|
req.IdempotencyKey,
|
|
current,
|
|
)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to create model job", err)
|
|
return
|
|
}
|
|
if statusCode == http.StatusAccepted {
|
|
go s.dispatchJob(record)
|
|
}
|
|
writeData(w, statusCode, map[string]any{
|
|
"job_id": record.JobID,
|
|
"status": record.Status,
|
|
"created_at": record.CreatedAt,
|
|
})
|
|
}
|
|
|
|
func (s *Server) dispatchJob(record *job) {
|
|
if record == nil {
|
|
return
|
|
}
|
|
|
|
switch normalizeRouteKey(record.RouteKey) {
|
|
case "image.text2image":
|
|
s.executeTextToImageJob(record)
|
|
case "image.img2img":
|
|
s.executeImageToImageJob(record)
|
|
case "video.image2video":
|
|
s.executeImageToVideoJob(record)
|
|
default:
|
|
s.executeUnsupportedSkill(record)
|
|
}
|
|
}
|
|
|
|
func (s *Server) executeTextToImageJob(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
modelID := strings.TrimSpace(record.ModelID)
|
|
if modelID == "" {
|
|
modelID = s.resolveModelID(record.RouteKey, record.ProjectID)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
refs, usage, err := s.newapi.generateImageRefs(ctx, record.UpstreamKey, modelID, record.Input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
|
"model_id": modelID,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
refs, err = s.persistResultRefs(ctx, record, refs)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{
|
|
"details": err.Error(),
|
|
"model_id": modelID,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
|
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
|
if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{
|
|
"details": err.Error(),
|
|
}); failErr != nil {
|
|
log.Printf("popiartServer: fail job %s after result ref error failed: %v", record.JobID, failErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) executeImageToImageJob(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
modelID := strings.TrimSpace(record.ModelID)
|
|
if modelID == "" {
|
|
modelID = s.resolveModelID(record.RouteKey, record.ProjectID)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
input := buildImageToImageInput(record)
|
|
imageRefs, err := s.resolveImageToImageReferences(ctx, record, input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
log.Printf(
|
|
"popiartServer: image job %s using model=%s refs=%s preserve_composition=%t strength=%v",
|
|
record.JobID,
|
|
modelID,
|
|
summarizeImageEditReferenceRoles(imageRefs),
|
|
boolValue(input["preserve_composition"]),
|
|
input["strength"],
|
|
)
|
|
|
|
var (
|
|
resultRefs []resultRef
|
|
usage map[string]any
|
|
)
|
|
if useMiniMaxImageGenerations(modelID) {
|
|
resultRefs, usage, err = s.newapi.generateMiniMaxImageRefs(ctx, record.UpstreamKey, modelID, input, imageRefs)
|
|
} else {
|
|
resultRefs, usage, err = s.newapi.generateEditedImageRefs(ctx, record.UpstreamKey, modelID, input, imageRefs)
|
|
}
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
|
"model_id": modelID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
resultRefs, err = s.persistResultRefs(ctx, record, resultRefs)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{
|
|
"details": err.Error(),
|
|
"model_id": modelID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := s.store.completeJobWithResults(record.JobID, resultRefs, usage); err != nil {
|
|
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
|
if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{
|
|
"details": err.Error(),
|
|
}); failErr != nil {
|
|
log.Printf("popiartServer: fail job %s after result ref error failed: %v", record.JobID, failErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) executeImageToVideoJob(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
|
|
if s.newapi == nil || !s.newapi.enabled() {
|
|
if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
modelID := strings.TrimSpace(record.ModelID)
|
|
if modelID == "" {
|
|
modelID = s.resolveModelID(record.RouteKey, record.ProjectID)
|
|
}
|
|
|
|
input := buildImageToVideoInput(record)
|
|
submitCtx, cancelSubmit := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancelSubmit()
|
|
|
|
var refsForMiniMax []imageEditReference
|
|
var ref imageEditReference
|
|
var err error
|
|
if useMiniMaxVideoGenerations(modelID) {
|
|
if hasVideoReferenceInput(input) {
|
|
refsForMiniMax, err = s.resolveVideoReferences(submitCtx, record, input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
ref, err = s.resolveImageToImageReference(submitCtx, record, input)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
var upstreamTaskID string
|
|
if useMiniMaxVideoGenerations(modelID) {
|
|
upstreamTaskID, err = s.newapi.submitMiniMaxVideoTask(submitCtx, record.UpstreamKey, modelID, input, refsForMiniMax)
|
|
} else {
|
|
upstreamTaskID, err = s.newapi.submitImageToVideoTask(submitCtx, record.UpstreamKey, modelID, input, ref)
|
|
}
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
|
"model_id": modelID,
|
|
"route_key": record.RouteKey,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := s.store.linkUpstreamTask(record.JobID, upstreamTaskID); err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "TASK_LINK_PERSIST_FAILED", "failed to persist upstream task id", map[string]any{
|
|
"details": err.Error(),
|
|
"newapi_task_id": upstreamTaskID,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
pollCtx, cancelPoll := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancelPoll()
|
|
|
|
taskResult, err := s.waitForVideoTask(pollCtx, record.UpstreamKey, upstreamTaskID)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "TASK_POLL_FAILED", err.Error(), map[string]any{
|
|
"model_id": modelID,
|
|
"route_key": record.RouteKey,
|
|
"newapi_task_id": upstreamTaskID,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
refs := []resultRef{buildVideoResultRef(s.newapi.baseURL, upstreamTaskID, modelID, taskResult)}
|
|
usage := map[string]any{
|
|
"newapi_task_id": upstreamTaskID,
|
|
"status": taskResult.Status,
|
|
}
|
|
if strings.TrimSpace(taskResult.Format) != "" {
|
|
usage["format"] = taskResult.Format
|
|
}
|
|
|
|
persistCtx, cancelPersist := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancelPersist()
|
|
refs, err = s.persistResultRefs(persistCtx, record, refs)
|
|
if err != nil {
|
|
if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{
|
|
"details": err.Error(),
|
|
"model_id": modelID,
|
|
"route_key": record.RouteKey,
|
|
"newapi_task_id": upstreamTaskID,
|
|
}); repoErr != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
|
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
|
if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{
|
|
"details": err.Error(),
|
|
}); failErr != nil {
|
|
log.Printf("popiartServer: fail job %s after result ref error failed: %v", record.JobID, failErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasVideoReferenceInput(input map[string]any) bool {
|
|
if input == nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(stringValue(
|
|
input["source_artifact_id"],
|
|
input["image"],
|
|
input["image_url"],
|
|
input["reference_image_url"],
|
|
)) != "" || len(extractStringValues(input["images"])) > 0
|
|
}
|
|
|
|
func (s *Server) executeUnsupportedSkill(record *job) {
|
|
if _, _, err := s.store.startJob(record.JobID); err != nil {
|
|
log.Printf("popiartServer: start job %s failed: %v", record.JobID, err)
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
if err := s.store.failJob(record.JobID, "NOT_IMPLEMENTED", "skill runtime is not connected yet", map[string]any{
|
|
"skill_id": record.SkillID,
|
|
"route_key": record.RouteKey,
|
|
"exec_mode": record.ExecMode,
|
|
}); err != nil {
|
|
log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) resolveModelID(routeKey, projectID string) string {
|
|
routes, err := s.store.routesForProject(projectID)
|
|
if err == nil {
|
|
if override := routes[normalizeRouteKey(routeKey)]; override != "" {
|
|
return override
|
|
}
|
|
}
|
|
switch normalizeRouteKey(routeKey) {
|
|
case "video.image2video":
|
|
return s.cfg.DefaultVideoModel
|
|
default:
|
|
return s.cfg.DefaultImageModel
|
|
}
|
|
}
|
|
|
|
func inferRouteKeyForModel(modelID string, input map[string]any) string {
|
|
if modelType, _ := classifyModelIDFallback(modelID); modelType == "video" {
|
|
return "video.image2video"
|
|
}
|
|
if input != nil {
|
|
if ref := strings.TrimSpace(stringValue(
|
|
input["source_artifact_id"],
|
|
input["image"],
|
|
input["reference_image_url"],
|
|
input["image_url"],
|
|
)); ref != "" {
|
|
return "image.img2img"
|
|
}
|
|
}
|
|
return "image.text2image"
|
|
}
|
|
|
|
func (s *Server) resolveImageToImageReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) {
|
|
if record == nil {
|
|
return nil, fmt.Errorf("job record is required")
|
|
}
|
|
if artifactID := strings.TrimSpace(stringValue(input["source_artifact_id"])); artifactID != "" {
|
|
sourceRef, err := s.resolveArtifactImageReference(ctx, record, artifactID, "source")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs := []imageEditReference{sourceRef}
|
|
seen := map[string]struct{}{artifactID: {}}
|
|
var appendArtifactRefs = func(role string, values []string) error {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
ref, err := s.resolveArtifactImageReference(ctx, record, value, role)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
seen[value] = struct{}{}
|
|
refs = append(refs, ref)
|
|
}
|
|
return nil
|
|
}
|
|
if err := appendArtifactRefs("identity", stringSliceValue(input["identity_reference_artifact_ids"])); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := appendArtifactRefs("style", stringSliceValue(input["style_reference_artifact_ids"])); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := appendArtifactRefs("reference", stringSliceValue(input["reference_artifact_ids"])); err != nil {
|
|
return nil, err
|
|
}
|
|
return refs, nil
|
|
}
|
|
|
|
refURL := strings.TrimSpace(stringValue(
|
|
input["image"],
|
|
input["reference_image_url"],
|
|
input["image_url"],
|
|
))
|
|
if refURL == "" && strings.Contains(record.SkillID, "popistudio-alice-showcase") {
|
|
refURL = defaultAliceReferenceURL
|
|
}
|
|
if refURL == "" {
|
|
return nil, fmt.Errorf("reference image is required")
|
|
}
|
|
ref, err := s.downloadReferenceImage(ctx, record.SessionID, refURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ref.Role = "source"
|
|
return []imageEditReference{ref}, nil
|
|
}
|
|
|
|
func (s *Server) resolveImageToImageReference(ctx context.Context, record *job, input map[string]any) (imageEditReference, error) {
|
|
refs, err := s.resolveImageToImageReferences(ctx, record, input)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
if len(refs) == 0 {
|
|
return imageEditReference{}, fmt.Errorf("reference image is required")
|
|
}
|
|
return refs[0], nil
|
|
}
|
|
|
|
func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) {
|
|
if record == nil {
|
|
return nil, fmt.Errorf("job record is required")
|
|
}
|
|
if artifactID := strings.TrimSpace(stringValue(input["source_artifact_id"])); artifactID != "" {
|
|
ref, err := s.resolveImageToImageReference(ctx, record, map[string]any{
|
|
"source_artifact_id": artifactID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []imageEditReference{ref}, nil
|
|
}
|
|
|
|
urls := extractStringValues(input["images"])
|
|
if len(urls) == 0 {
|
|
if refURL := strings.TrimSpace(stringValue(
|
|
input["image"],
|
|
input["reference_image_url"],
|
|
input["image_url"],
|
|
)); refURL != "" {
|
|
urls = []string{refURL}
|
|
}
|
|
}
|
|
if len(urls) == 0 {
|
|
return nil, fmt.Errorf("reference image is required")
|
|
}
|
|
|
|
refs := make([]imageEditReference, 0, len(urls))
|
|
for _, rawURL := range urls {
|
|
ref, err := s.downloadReferenceImage(ctx, record.SessionID, rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs = append(refs, ref)
|
|
}
|
|
return refs, nil
|
|
}
|
|
|
|
func extractStringValues(value any) []string {
|
|
switch typed := value.(type) {
|
|
case []string:
|
|
out := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
if text := strings.TrimSpace(item); text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
if text := strings.TrimSpace(stringValue(item)); text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *Server) waitForVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) {
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
result, err := s.newapi.fetchVideoTask(ctx, token, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch result.Status {
|
|
case "completed":
|
|
return result, nil
|
|
case "failed":
|
|
message := defaultString(strings.TrimSpace(result.ErrorReason), "video task failed")
|
|
return nil, errors.New(message)
|
|
case "queued", "in_progress", "":
|
|
default:
|
|
if strings.TrimSpace(result.Status) != "" {
|
|
return nil, fmt.Errorf("unexpected video task status: %s", result.Status)
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) downloadReferenceImage(ctx context.Context, sessionToken, rawURL string) (imageEditReference, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
if sessionToken = strings.TrimSpace(sessionToken); sessionToken != "" && shouldAttachAuthHeader(publicBaseURL(s.cfg), rawURL) {
|
|
req.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
}
|
|
resp, err := s.newapi.httpClient.Do(req)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return imageEditReference{}, fmt.Errorf("download reference image failed: %s", strings.TrimSpace(string(body)))
|
|
}
|
|
content, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
contentType := defaultString(strings.TrimSpace(resp.Header.Get("Content-Type")), http.DetectContentType(content))
|
|
return imageEditReference{
|
|
Filename: filenameFromURL(rawURL, "reference"+extensionFromContentType(contentType)),
|
|
ContentType: contentType,
|
|
Content: content,
|
|
URL: strings.TrimSpace(rawURL),
|
|
}, nil
|
|
}
|
|
|
|
func buildImageToImageInput(record *job) map[string]any {
|
|
input := cloneMap(record.Input)
|
|
if input == nil {
|
|
input = map[string]any{}
|
|
}
|
|
|
|
prompt := strings.TrimSpace(stringValue(
|
|
input["prompt"],
|
|
input["scene_prompt"],
|
|
))
|
|
|
|
if strings.Contains(record.SkillID, "popistudio-alice-showcase") {
|
|
prompt = buildAliceShowcasePrompt(input, prompt)
|
|
} else {
|
|
prompt = buildGenericImageToImagePrompt(input, prompt)
|
|
}
|
|
|
|
input["prompt"] = prompt
|
|
delete(input, "scene_prompt")
|
|
return input
|
|
}
|
|
|
|
func buildImageToVideoInput(record *job) map[string]any {
|
|
input := cloneMap(record.Input)
|
|
if input == nil {
|
|
input = map[string]any{}
|
|
}
|
|
|
|
prompt := strings.TrimSpace(stringValue(
|
|
input["prompt"],
|
|
input["motion_prompt"],
|
|
input["scene_prompt"],
|
|
))
|
|
|
|
parts := make([]string, 0, 6)
|
|
if prompt != "" {
|
|
parts = append(parts, prompt)
|
|
}
|
|
if cameraMotion := strings.TrimSpace(stringValue(input["camera_motion"])); cameraMotion != "" {
|
|
parts = append(parts, "camera motion "+cameraMotion)
|
|
}
|
|
if shotType := strings.TrimSpace(stringValue(input["shot_type"])); shotType != "" {
|
|
parts = append(parts, shotType)
|
|
}
|
|
if mood := strings.TrimSpace(stringValue(input["mood"])); mood != "" {
|
|
parts = append(parts, mood)
|
|
}
|
|
if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" {
|
|
parts = append(parts, "aspect ratio "+aspectRatio)
|
|
}
|
|
if len(parts) == 0 {
|
|
parts = append(parts, "Generate a short polished cinematic motion clip that preserves the reference image subject identity.")
|
|
}
|
|
|
|
input["prompt"] = strings.Join(parts, ", ")
|
|
delete(input, "motion_prompt")
|
|
delete(input, "scene_prompt")
|
|
return input
|
|
}
|
|
|
|
func buildAliceShowcasePrompt(input map[string]any, scenePrompt string) string {
|
|
parts := []string{
|
|
"PopiStudio Alice as the same fixed main protagonist from the canonical Alice reference image",
|
|
"preserve the same anime facial structure, recognizability, hairstyle, hair color, clothing language, and main palette",
|
|
"Alice remains the visual center of the frame",
|
|
}
|
|
|
|
if scenePrompt != "" {
|
|
parts = append(parts, scenePrompt)
|
|
}
|
|
if shotType := strings.TrimSpace(stringValue(input["shot_type"])); shotType != "" {
|
|
parts = append(parts, shotType)
|
|
}
|
|
if camera := strings.TrimSpace(stringValue(input["camera"])); camera != "" {
|
|
parts = append(parts, camera)
|
|
}
|
|
if mood := strings.TrimSpace(stringValue(input["mood"])); mood != "" {
|
|
parts = append(parts, mood)
|
|
}
|
|
if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" {
|
|
parts = append(parts, "aspect ratio "+aspectRatio)
|
|
}
|
|
|
|
parts = append(parts,
|
|
"real-world modern Chinese living scene",
|
|
"Sony photo realism",
|
|
"natural light",
|
|
"warm realistic tone",
|
|
"high detail",
|
|
"clean composition",
|
|
)
|
|
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (s *Server) resolveArtifactImageReference(ctx context.Context, record *job, artifactID, role string) (imageEditReference, error) {
|
|
item, ref, exists, err := s.store.artifactRef(record.UserID, artifactID)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
if !exists {
|
|
return imageEditReference{}, fmt.Errorf("%s artifact not found: %s", defaultString(role, "reference"), artifactID)
|
|
}
|
|
contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
defer reader.Close()
|
|
content, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return imageEditReference{}, err
|
|
}
|
|
return imageEditReference{
|
|
Role: role,
|
|
Filename: item.Filename,
|
|
ContentType: defaultString(contentType, item.ContentType),
|
|
Content: content,
|
|
URL: artifactView(s.cfg, *item, time.Now().UTC()).URL,
|
|
}, nil
|
|
}
|
|
|
|
func buildGenericImageToImagePrompt(input map[string]any, prompt string) string {
|
|
parts := make([]string, 0, 6)
|
|
if prompt != "" {
|
|
parts = append(parts, prompt)
|
|
}
|
|
if refURL := strings.TrimSpace(stringValue(input["image"], input["reference_image_url"], input["image_url"])); refURL != "" {
|
|
parts = append(parts, "preserve the same main subject identity and key visual traits from the reference image")
|
|
}
|
|
if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" {
|
|
parts = append(parts, "aspect ratio "+aspectRatio)
|
|
}
|
|
if len(parts) == 0 {
|
|
return "preserve the same main subject identity and render a polished high-detail image"
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func summarizeImageEditReferenceRoles(refs []imageEditReference) string {
|
|
if len(refs) == 0 {
|
|
return "none"
|
|
}
|
|
parts := make([]string, 0, len(refs))
|
|
for _, ref := range refs {
|
|
role := strings.TrimSpace(ref.Role)
|
|
if role == "" {
|
|
role = "reference"
|
|
}
|
|
parts = append(parts, role)
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
func stringValue(values ...any) string {
|
|
for _, value := range values {
|
|
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
|
return text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func boolValue(value any) bool {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func stringSliceValue(value any) []string {
|
|
switch typed := value.(type) {
|
|
case []string:
|
|
return cleanedStringValues(typed)
|
|
case []any:
|
|
items := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
items = append(items, strings.TrimSpace(fmt.Sprint(item)))
|
|
}
|
|
return cleanedStringValues(items)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func cleanedStringValues(values []string) []string {
|
|
items := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
items = append(items, trimmed)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func buildVideoResultRef(baseURL, taskID, modelID string, task *videoTaskResult) resultRef {
|
|
urlValue := strings.TrimSpace(task.URL)
|
|
if urlValue == "" {
|
|
urlValue = strings.TrimRight(baseURL, "/") + "/v1/videos/" + taskID + "/content"
|
|
}
|
|
|
|
contentType := "video/mp4"
|
|
ext := ".mp4"
|
|
if format := strings.TrimSpace(task.Format); format != "" {
|
|
switch strings.ToLower(strings.TrimPrefix(format, ".")) {
|
|
case "mov", "quicktime":
|
|
contentType = "video/quicktime"
|
|
ext = ".mov"
|
|
case "webm":
|
|
contentType = "video/webm"
|
|
ext = ".webm"
|
|
case "mp4":
|
|
contentType = "video/mp4"
|
|
ext = ".mp4"
|
|
}
|
|
}
|
|
filename := filenameFromURL(urlValue, "")
|
|
if filename == "" || filename == "content" || !strings.Contains(filepath.Base(filename), ".") {
|
|
filename = sanitizeFilename(defaultString(modelID, "video")) + "-" + sanitizeFilename(taskID) + ext
|
|
}
|
|
|
|
if strings.HasPrefix(urlValue, "data:") {
|
|
return resultRef{
|
|
Kind: "data_url",
|
|
DataURL: urlValue,
|
|
Filename: filename,
|
|
ContentType: contentType,
|
|
}
|
|
}
|
|
return resultRef{
|
|
Kind: "url",
|
|
URL: urlValue,
|
|
Filename: filename,
|
|
ContentType: contentType,
|
|
}
|
|
}
|
|
|
|
const defaultAliceReferenceURL = "http://8.136.121.101:8790/media/Character_id_card/alice.jpg"
|
|
|
|
func (s *Server) authenticateSession(w http.ResponseWriter, r *http.Request) (session, bool) {
|
|
current, ok, err := s.sessionFromRequest(r)
|
|
if err != nil {
|
|
writeInternalError(w, "failed to load session", err)
|
|
return session{}, false
|
|
}
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing bearer key", nil)
|
|
return session{}, false
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
func (s *Server) sessionFromRequest(r *http.Request) (session, bool, error) {
|
|
token, ok := bearerToken(r)
|
|
if !ok {
|
|
return session{}, false, nil
|
|
}
|
|
current, exists, err := s.store.session(token)
|
|
if err != nil {
|
|
return session{}, false, err
|
|
}
|
|
if !exists {
|
|
return session{}, false, nil
|
|
}
|
|
return current, true, nil
|
|
}
|
|
|
|
func bearerToken(r *http.Request) (string, bool) {
|
|
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")), true
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|
defer r.Body.Close()
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "failed to read request body", nil)
|
|
return false
|
|
}
|
|
if len(strings.TrimSpace(string(body))) == 0 {
|
|
body = []byte(`{}`)
|
|
}
|
|
if err := json.Unmarshal(body, dst); err != nil {
|
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "invalid JSON body", map[string]any{
|
|
"details": err.Error(),
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func intQuery(r *http.Request, key string, fallback int) int {
|
|
value := r.URL.Query().Get(key)
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil || n < 0 {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
func writeData(w http.ResponseWriter, status int, data any) {
|
|
writeJSON(w, status, map[string]any{
|
|
"ok": true,
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
func buildDataURL(contentType string, content []byte) string {
|
|
return "data:" + defaultString(strings.TrimSpace(contentType), "application/octet-stream") + ";base64," + base64.StdEncoding.EncodeToString(content)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code, message string, details map[string]any) {
|
|
errBody := map[string]any{
|
|
"code": code,
|
|
"message": message,
|
|
}
|
|
for key, value := range details {
|
|
errBody[key] = value
|
|
}
|
|
writeJSON(w, status, map[string]any{
|
|
"ok": false,
|
|
"error": errBody,
|
|
})
|
|
}
|
|
|
|
func writeInternalError(w http.ResponseWriter, message string, err error) {
|
|
details := map[string]any{}
|
|
if err != nil {
|
|
details["details"] = err.Error()
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "SERVER_ERROR", message, details)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func notFound(w http.ResponseWriter) {
|
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "resource not found", nil)
|
|
}
|
|
|
|
func methodNotAllowed(w http.ResponseWriter) {
|
|
writeError(w, http.StatusMethodNotAllowed, "BAD_REQUEST", "method not allowed", nil)
|
|
}
|
|
|
|
func mustJSON(value any) string {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return `{"level":"error","message":"marshal failed"}`
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func defaultString(value, fallback string) string {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|