package server import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net/url" "os" "path/filepath" "strconv" "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 mediaContentPath(mediaID string) string { return "/v1/media/" + url.PathEscape(strings.TrimSpace(mediaID)) + "/content" } func mediaContentURL(cfg Config, mediaID string) string { return publicBaseURL(cfg) + mediaContentPath(mediaID) } func mediaURLTTL() time.Duration { return 24 * time.Hour } func mediaURLExpiry(now time.Time) time.Time { if now.IsZero() { now = time.Now().UTC() } return now.UTC().Add(mediaURLTTL()) } func mediaURLSecret(cfg Config) []byte { return []byte(defaultString(strings.TrimSpace(cfg.SessionSecret), "popiart-dev-session-secret")) } func signedMediaContentURL(cfg Config, mediaID string, now time.Time) string { expiresAt := mediaURLExpiry(now) baseURL := mediaContentURL(cfg, mediaID) exp := strconv.FormatInt(expiresAt.Unix(), 10) sig := signMediaAccess(cfg, mediaID, exp) return baseURL + "?exp=" + url.QueryEscape(exp) + "&sig=" + url.QueryEscape(sig) } func signMediaAccess(cfg Config, mediaID, exp string) string { mac := hmac.New(sha256.New, mediaURLSecret(cfg)) _, _ = mac.Write([]byte(strings.TrimSpace(mediaID))) _, _ = mac.Write([]byte("\n")) _, _ = mac.Write([]byte(strings.TrimSpace(exp))) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func validateSignedMediaAccess(cfg Config, mediaID, exp, sig string, now time.Time) bool { exp = strings.TrimSpace(exp) sig = strings.TrimSpace(sig) if exp == "" || sig == "" { return false } unix, err := strconv.ParseInt(exp, 10, 64) if err != nil { return false } if now.IsZero() { now = time.Now().UTC() } if now.UTC().After(time.Unix(unix, 0).UTC()) { return false } expected := signMediaAccess(cfg, mediaID, exp) return hmac.Equal([]byte(expected), []byte(sig)) } func mediaView(cfg Config, record mediaRecord, now time.Time) media { view := record.media view.URL = signedMediaContentURL(cfg, record.ID, now) return view } func artifactView(cfg Config, item artifact, now time.Time) artifact { view := item if strings.TrimSpace(view.MediaID) != "" { view.URL = signedMediaContentURL(cfg, view.MediaID, now) } return view } func artifactViews(cfg Config, items []artifact, now time.Time) []artifact { if len(items) == 0 { return nil } out := make([]artifact, 0, len(items)) for _, item := range items { out = append(out, artifactView(cfg, item, now)) } return out } func mediaContentURLCanonical(cfg Config, mediaID string) string { return publicBaseURL(cfg) + "/v1/media/" + url.PathEscape(strings.TrimSpace(mediaID)) + "/content" } func persistMediaContent(cfg Config, repo MediaRepository, 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: mediaContentURLCanonical(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 } if repo != nil { if err := repo.UpsertMedia(record); err != nil { return mediaRecord{}, err } } return record, nil } func loadMediaRecordFromJSON(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 := s.store.persistMediaContent( 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 }