You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
193 lines
5.0 KiB
193 lines
5.0 KiB
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
|
|
}
|
|
|