10 changed files with 789 additions and 39 deletions
@ -0,0 +1,193 @@ |
|||||
|
package server |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"crypto/sha256" |
||||
|
"encoding/hex" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"net/url" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"strings" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
type mediaRecord struct { |
||||
|
media |
||||
|
UserID string `json:"user_id"` |
||||
|
LocalPath string `json:"local_path"` |
||||
|
} |
||||
|
|
||||
|
func mediaDir(cfg Config) string { |
||||
|
return filepath.Join(cfg.DataDir, "media") |
||||
|
} |
||||
|
|
||||
|
func mediaBlobDir(cfg Config) string { |
||||
|
return filepath.Join(mediaDir(cfg), "blobs") |
||||
|
} |
||||
|
|
||||
|
func mediaMetaDir(cfg Config) string { |
||||
|
return filepath.Join(mediaDir(cfg), "meta") |
||||
|
} |
||||
|
|
||||
|
func mediaMetaPath(cfg Config, mediaID string) string { |
||||
|
return filepath.Join(mediaMetaDir(cfg), mediaID+".json") |
||||
|
} |
||||
|
|
||||
|
func mediaBlobPath(cfg Config, mediaID, filename string) string { |
||||
|
ext := filepath.Ext(filename) |
||||
|
if ext == "" { |
||||
|
ext = extensionFromContentType("") |
||||
|
} |
||||
|
return filepath.Join(mediaBlobDir(cfg), mediaID+ext) |
||||
|
} |
||||
|
|
||||
|
func publicBaseURL(cfg Config) string { |
||||
|
base := strings.TrimSpace(cfg.PublicBaseURL) |
||||
|
if base == "" { |
||||
|
base = strings.TrimSpace(os.Getenv("POPIART_SERVER_ADDR")) |
||||
|
} |
||||
|
if base == "" { |
||||
|
base = "127.0.0.1:8080" |
||||
|
} |
||||
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { |
||||
|
base = "http://" + base |
||||
|
} |
||||
|
base = strings.Replace(base, "0.0.0.0", "127.0.0.1", 1) |
||||
|
return strings.TrimRight(base, "/") |
||||
|
} |
||||
|
|
||||
|
func mediaContentURL(cfg Config, mediaID string) string { |
||||
|
return publicBaseURL(cfg) + "/v1/media/" + url.PathEscape(strings.TrimSpace(mediaID)) + "/content" |
||||
|
} |
||||
|
|
||||
|
func persistMediaContent(cfg Config, userID, projectID, artifactID, filename, contentType, visibility string, content []byte, mediaID string) (mediaRecord, error) { |
||||
|
if len(content) == 0 { |
||||
|
return mediaRecord{}, fmt.Errorf("media content is required") |
||||
|
} |
||||
|
filename = sanitizeFilename(strings.TrimSpace(filename)) |
||||
|
if filename == "" || filename == "artifact" { |
||||
|
filename = "artifact" + extensionFromContentType(contentType) |
||||
|
} |
||||
|
contentType = strings.TrimSpace(contentType) |
||||
|
if contentType == "" { |
||||
|
contentType = "application/octet-stream" |
||||
|
} |
||||
|
visibility = strings.TrimSpace(visibility) |
||||
|
if visibility == "" { |
||||
|
visibility = "unlisted" |
||||
|
} |
||||
|
mediaID = strings.TrimSpace(mediaID) |
||||
|
if mediaID == "" { |
||||
|
mediaID = "med_" + randomID() |
||||
|
} |
||||
|
if err := os.MkdirAll(mediaBlobDir(cfg), 0o755); err != nil { |
||||
|
return mediaRecord{}, err |
||||
|
} |
||||
|
if err := os.MkdirAll(mediaMetaDir(cfg), 0o755); err != nil { |
||||
|
return mediaRecord{}, err |
||||
|
} |
||||
|
|
||||
|
sum := sha256.Sum256(content) |
||||
|
blobPath := mediaBlobPath(cfg, mediaID, filename) |
||||
|
if err := os.WriteFile(blobPath, content, 0o644); err != nil { |
||||
|
return mediaRecord{}, err |
||||
|
} |
||||
|
|
||||
|
record := mediaRecord{ |
||||
|
media: media{ |
||||
|
ID: mediaID, |
||||
|
ArtifactID: strings.TrimSpace(artifactID), |
||||
|
ProjectID: strings.TrimSpace(projectID), |
||||
|
Filename: filename, |
||||
|
ContentType: contentType, |
||||
|
SizeBytes: int64(len(content)), |
||||
|
CreatedAt: time.Now().UTC().Format(time.RFC3339), |
||||
|
URL: mediaContentURL(cfg, mediaID), |
||||
|
Visibility: visibility, |
||||
|
SHA256: hex.EncodeToString(sum[:]), |
||||
|
}, |
||||
|
UserID: strings.TrimSpace(userID), |
||||
|
LocalPath: blobPath, |
||||
|
} |
||||
|
|
||||
|
metaBytes, err := json.Marshal(record) |
||||
|
if err != nil { |
||||
|
return mediaRecord{}, err |
||||
|
} |
||||
|
if err := os.WriteFile(mediaMetaPath(cfg, mediaID), metaBytes, 0o644); err != nil { |
||||
|
return mediaRecord{}, err |
||||
|
} |
||||
|
return record, nil |
||||
|
} |
||||
|
|
||||
|
func loadMediaRecord(cfg Config, mediaID string) (*mediaRecord, bool, error) { |
||||
|
mediaID = strings.TrimSpace(mediaID) |
||||
|
if mediaID == "" { |
||||
|
return nil, false, nil |
||||
|
} |
||||
|
data, err := os.ReadFile(mediaMetaPath(cfg, mediaID)) |
||||
|
if os.IsNotExist(err) { |
||||
|
return nil, false, nil |
||||
|
} |
||||
|
if err != nil { |
||||
|
return nil, false, err |
||||
|
} |
||||
|
var record mediaRecord |
||||
|
if err := json.Unmarshal(data, &record); err != nil { |
||||
|
return nil, false, err |
||||
|
} |
||||
|
return &record, true, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Server) persistResultRefs(ctx context.Context, record *job, refs []resultRef) ([]resultRef, error) { |
||||
|
if record == nil || len(refs) == 0 { |
||||
|
return refs, nil |
||||
|
} |
||||
|
items := make([]resultRef, 0, len(refs)) |
||||
|
for idx, ref := range refs { |
||||
|
contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
content, readErr := io.ReadAll(reader) |
||||
|
reader.Close() |
||||
|
if readErr != nil { |
||||
|
return nil, readErr |
||||
|
} |
||||
|
filename := strings.TrimSpace(ref.Filename) |
||||
|
if filename == "" { |
||||
|
filename = inferFilenameFromRef(ref, buildArtifactID(record.JobID, idx)) |
||||
|
} |
||||
|
artifactID := buildArtifactID(record.JobID, idx) |
||||
|
mediaRecord, err := persistMediaContent( |
||||
|
s.cfg, |
||||
|
record.UserID, |
||||
|
record.ProjectID, |
||||
|
artifactID, |
||||
|
filename, |
||||
|
defaultString(strings.TrimSpace(contentType), strings.TrimSpace(ref.ContentType)), |
||||
|
defaultString(strings.TrimSpace(ref.Visibility), "unlisted"), |
||||
|
content, |
||||
|
"", |
||||
|
) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
items = append(items, resultRef{ |
||||
|
Kind: "local_path", |
||||
|
URL: mediaRecord.URL, |
||||
|
LocalPath: mediaRecord.LocalPath, |
||||
|
MediaID: mediaRecord.ID, |
||||
|
Filename: mediaRecord.Filename, |
||||
|
ContentType: mediaRecord.ContentType, |
||||
|
SizeBytes: mediaRecord.SizeBytes, |
||||
|
Visibility: mediaRecord.Visibility, |
||||
|
SHA256: mediaRecord.SHA256, |
||||
|
StorageStatus: "ready", |
||||
|
}) |
||||
|
} |
||||
|
return items, nil |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
package server |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"encoding/json" |
||||
|
"io" |
||||
|
"mime/multipart" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"path/filepath" |
||||
|
"testing" |
||||
|
) |
||||
|
|
||||
|
func TestMediaUploadGetAndContent(t *testing.T) { |
||||
|
cfg := Config{ |
||||
|
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), |
||||
|
SkillhubDir: makeEmptySkillhub(t), |
||||
|
SessionSecret: "test-secret", |
||||
|
} |
||||
|
server, err := NewWithConfig(cfg) |
||||
|
if err != nil { |
||||
|
t.Fatalf("NewWithConfig: %v", err) |
||||
|
} |
||||
|
srv := httptest.NewServer(server.Handler()) |
||||
|
defer srv.Close() |
||||
|
server.cfg.PublicBaseURL = srv.URL |
||||
|
|
||||
|
sessionToken, _, ok, err := server.store.createSession("sk-media-user") |
||||
|
if err != nil { |
||||
|
t.Fatalf("createSession: %v", err) |
||||
|
} |
||||
|
if !ok { |
||||
|
t.Fatal("expected session creation to succeed") |
||||
|
} |
||||
|
|
||||
|
imageBytes := tinyPNG(t) |
||||
|
var body bytes.Buffer |
||||
|
writer := multipart.NewWriter(&body) |
||||
|
if err := writer.WriteField("project_id", "proj_media_demo"); err != nil { |
||||
|
t.Fatalf("write project_id field: %v", err) |
||||
|
} |
||||
|
if err := writer.WriteField("visibility", "public"); err != nil { |
||||
|
t.Fatalf("write visibility field: %v", err) |
||||
|
} |
||||
|
part, err := writer.CreateFormFile("file", "poster.png") |
||||
|
if err != nil { |
||||
|
t.Fatalf("create form file: %v", err) |
||||
|
} |
||||
|
if _, err := part.Write(imageBytes); err != nil { |
||||
|
t.Fatalf("write form file: %v", err) |
||||
|
} |
||||
|
if err := writer.Close(); err != nil { |
||||
|
t.Fatalf("close multipart writer: %v", err) |
||||
|
} |
||||
|
|
||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/media/upload", &body) |
||||
|
req.Header.Set("Authorization", "Bearer "+sessionToken) |
||||
|
req.Header.Set("Content-Type", writer.FormDataContentType()) |
||||
|
rec := httptest.NewRecorder() |
||||
|
server.Handler().ServeHTTP(rec, req) |
||||
|
|
||||
|
if rec.Code != http.StatusCreated { |
||||
|
t.Fatalf("expected status 201, got %d body=%s", rec.Code, rec.Body.String()) |
||||
|
} |
||||
|
|
||||
|
var envelope struct { |
||||
|
OK bool `json:"ok"` |
||||
|
Data media `json:"data"` |
||||
|
} |
||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { |
||||
|
t.Fatalf("decode upload response: %v body=%s", err, rec.Body.String()) |
||||
|
} |
||||
|
if !envelope.OK { |
||||
|
t.Fatalf("expected ok upload response, got %s", rec.Body.String()) |
||||
|
} |
||||
|
if envelope.Data.ID == "" { |
||||
|
t.Fatalf("expected media id, got %#v", envelope.Data) |
||||
|
} |
||||
|
if envelope.Data.URL == "" { |
||||
|
t.Fatalf("expected stable url, got %#v", envelope.Data) |
||||
|
} |
||||
|
|
||||
|
getReq := httptest.NewRequest(http.MethodGet, "/v1/media/"+envelope.Data.ID, nil) |
||||
|
getReq.Header.Set("Authorization", "Bearer "+sessionToken) |
||||
|
getRec := httptest.NewRecorder() |
||||
|
server.Handler().ServeHTTP(getRec, getReq) |
||||
|
if getRec.Code != http.StatusOK { |
||||
|
t.Fatalf("expected media get 200, got %d body=%s", getRec.Code, getRec.Body.String()) |
||||
|
} |
||||
|
|
||||
|
contentResp, err := http.Get(envelope.Data.URL) |
||||
|
if err != nil { |
||||
|
t.Fatalf("GET media content: %v", err) |
||||
|
} |
||||
|
defer contentResp.Body.Close() |
||||
|
if contentResp.StatusCode != http.StatusOK { |
||||
|
t.Fatalf("expected media content 200, got %d", contentResp.StatusCode) |
||||
|
} |
||||
|
content, err := io.ReadAll(contentResp.Body) |
||||
|
if err != nil { |
||||
|
t.Fatalf("read content body: %v", err) |
||||
|
} |
||||
|
if !bytes.Equal(content, imageBytes) { |
||||
|
t.Fatal("expected media content bytes to match uploaded file") |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue