package creator import ( "context" "errors" "fmt" "mime" "os" "path/filepath" "regexp" "github.com/sirupsen/logrus" ) const maxWorkCoverBytes = 4 << 20 var coverComponent = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) var coverExtensions = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/gif": ".gif", "image/avif": ".avif", } func (s *Store) workCoverBase(ctx context.Context, workID string) (string, error) { if workID == "" { return "", ErrInvalid } var uid, workKey string err := s.db.QueryRowContext(ctx, ` SELECT COALESCE(a.platform_account_key, c.platform_account_key, ''), w.work_key FROM creator_work w LEFT JOIN social_account a ON w.source_type = 'owned' AND a.account_id = w.source_id LEFT JOIN creator_competitor c ON w.source_type = 'competitor' AND c.competitor_id = w.source_id WHERE w.work_id = $1`, workID).Scan(&uid, &workKey) if err != nil { return "", rowError(err) } if !coverComponent.MatchString(uid) || !coverComponent.MatchString(workKey) { return "", fmt.Errorf("%w: cover requires a valid account UID and platform work ID", ErrInvalid) } return filepath.Join(s.coverDirectory, uid, workKey), nil } // SaveWorkCover writes /. atomically. func (s *Store) SaveWorkCover(ctx context.Context, workID, contentType string, data []byte) error { mediaType, _, err := mime.ParseMediaType(contentType) extension, supported := coverExtensions[mediaType] if err != nil || !supported || len(data) == 0 || len(data) > maxWorkCoverBytes { return ErrInvalid } base, err := s.workCoverBase(ctx, workID) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(base), 0755); err != nil { return fmt.Errorf("create cover directory: %w", err) } file, err := os.CreateTemp(filepath.Dir(base), ".cover-*") if err != nil { return fmt.Errorf("create cover file: %w", err) } defer func() { if err := os.Remove(file.Name()); err != nil && !errors.Is(err, os.ErrNotExist) { logrus.WithError(err).WithField("file", file.Name()).Warn("temporary work cover cleanup failed") } }() if _, err := file.Write(data); err != nil { return errors.Join(fmt.Errorf("write cover: %w", err), file.Close()) } if err := file.Sync(); err != nil { return errors.Join(fmt.Errorf("flush cover: %w", err), file.Close()) } if err := file.Close(); err != nil { return fmt.Errorf("close cover: %w", err) } if err := os.Rename(file.Name(), base+extension); err != nil { return fmt.Errorf("publish cover: %w", err) } for _, other := range coverExtensions { if other == extension { continue } if err := os.Remove(base + other); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("remove replaced cover: %w", err) } } return nil } // GetWorkCover returns the local file; image bytes are never stored in PostgreSQL. func (s *Store) GetWorkCover(ctx context.Context, workID string) (string, error) { base, err := s.workCoverBase(ctx, workID) if err != nil { return "", err } var result string for _, extension := range coverExtensions { path := base + extension info, err := os.Stat(path) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return "", fmt.Errorf("stat cover: %w", err) } if !info.Mode().IsRegular() || info.Size() == 0 { return "", fmt.Errorf("%w: cover file is not a nonempty regular file", ErrInvalid) } if result != "" { return "", fmt.Errorf("%w: multiple cover formats exist for work %s", ErrConflict, workID) } result = path } if result == "" { return "", ErrNotFound } return result, nil } // ListWorksMissingCover backfills both owned and monitored sources, at most 40 per sync. func (s *Store) ListWorksMissingCover(ctx context.Context, sourceType, sourceID string) ([]Work, error) { if (sourceType != SourceOwned && sourceType != SourceCompetitor) || sourceID == "" { return nil, ErrInvalid } rows, err := s.db.QueryContext(ctx, workSelect+` WHERE source_type = $1 AND source_id = $2 AND cover_url <> '' ORDER BY created_at DESC`, sourceType, sourceID) if err != nil { return nil, databaseError(err) } defer rows.Close() works := make([]Work, 0) for rows.Next() { work, err := scanWork(rows) if err != nil { return nil, err } works = append(works, work) } if err := rows.Err(); err != nil { return nil, err } if err := rows.Close(); err != nil { return nil, err } missing := make([]Work, 0) for _, work := range works { _, err := s.GetWorkCover(ctx, work.ID) if err == nil { continue } if !errors.Is(err, ErrNotFound) { return nil, err } missing = append(missing, work) if len(missing) == 40 { break } } return missing, nil }