Make artifact and media metadata first-class server records
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
This commit is contained in:
+77
-21
@@ -63,6 +63,7 @@ func (s *Server) routes() {
|
||||
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)
|
||||
@@ -409,6 +410,34 @@ func (s *Server) handleJob(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -504,8 +533,7 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
refMedia, err := persistMediaContent(
|
||||
s.cfg,
|
||||
refMedia, err := s.store.persistMediaContent(
|
||||
current.User.ID,
|
||||
projectID,
|
||||
buildArtifactID(record.JobID, 0),
|
||||
@@ -546,7 +574,7 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) {
|
||||
writeInternalError(w, "uploaded artifact missing after persistence", errors.New("artifact not found"))
|
||||
return
|
||||
}
|
||||
writeData(w, http.StatusCreated, item)
|
||||
writeData(w, http.StatusCreated, artifactView(s.cfg, *item, time.Now().UTC()))
|
||||
}
|
||||
|
||||
func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -612,8 +640,7 @@ func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
record, err := persistMediaContent(
|
||||
s.cfg,
|
||||
record, err := s.store.persistMediaContent(
|
||||
current.User.ID,
|
||||
strings.TrimSpace(r.FormValue("project_id")),
|
||||
"",
|
||||
@@ -627,7 +654,7 @@ func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
|
||||
writeInternalError(w, "failed to persist media", err)
|
||||
return
|
||||
}
|
||||
writeData(w, http.StatusCreated, record.media)
|
||||
writeData(w, http.StatusCreated, mediaView(s.cfg, record, time.Now().UTC()))
|
||||
}
|
||||
|
||||
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -638,7 +665,7 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
record, exists, err := loadMediaRecord(s.cfg, parts[0])
|
||||
record, exists, err := s.store.getMedia(parts[0])
|
||||
if err != nil {
|
||||
writeInternalError(w, "failed to load media", err)
|
||||
return
|
||||
@@ -649,6 +676,22 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -676,7 +719,7 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 && r.Method == http.MethodGet {
|
||||
writeData(w, http.StatusOK, record.media)
|
||||
writeData(w, http.StatusOK, mediaView(s.cfg, *record, time.Now().UTC()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -707,7 +750,7 @@ func (s *Server) handleArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(parts) == 1 && r.Method == http.MethodGet {
|
||||
writeData(w, http.StatusOK, item)
|
||||
writeData(w, http.StatusOK, artifactView(s.cfg, *item, time.Now().UTC()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1454,7 +1497,7 @@ func (s *Server) resolveImageToImageReferences(ctx context.Context, record *job,
|
||||
if refURL == "" {
|
||||
return nil, fmt.Errorf("reference image is required")
|
||||
}
|
||||
ref, err := s.downloadReferenceImage(ctx, refURL)
|
||||
ref, err := s.downloadReferenceImage(ctx, record.SessionID, refURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1503,7 +1546,7 @@ func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input
|
||||
|
||||
refs := make([]imageEditReference, 0, len(urls))
|
||||
for _, rawURL := range urls {
|
||||
ref, err := s.downloadReferenceImage(ctx, rawURL)
|
||||
ref, err := s.downloadReferenceImage(ctx, record.SessionID, rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1565,11 +1608,14 @@ func (s *Server) waitForVideoTask(ctx context.Context, token, taskID string) (*v
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) downloadReferenceImage(ctx context.Context, rawURL string) (imageEditReference, error) {
|
||||
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
|
||||
@@ -1709,7 +1755,7 @@ func (s *Server) resolveArtifactImageReference(ctx context.Context, record *job,
|
||||
Filename: item.Filename,
|
||||
ContentType: defaultString(contentType, item.ContentType),
|
||||
Content: content,
|
||||
URL: item.URL,
|
||||
URL: artifactView(s.cfg, *item, time.Now().UTC()).URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1833,23 +1879,33 @@ func buildVideoResultRef(baseURL, taskID, modelID string, task *videoTaskResult)
|
||||
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) {
|
||||
token, ok := bearerToken(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "missing bearer key", nil)
|
||||
return session{}, false
|
||||
}
|
||||
current, exists, err := s.store.session(token)
|
||||
current, ok, err := s.sessionFromRequest(r)
|
||||
if err != nil {
|
||||
writeInternalError(w, "failed to load session", err)
|
||||
return session{}, false
|
||||
}
|
||||
if !exists {
|
||||
writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "key invalid or expired", nil)
|
||||
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 ") {
|
||||
|
||||
Reference in New Issue
Block a user