popiart-server
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.
 
 
 
 
 

284 lines
7.5 KiB

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
}