158 lines
4.6 KiB
Go
158 lines
4.6 KiB
Go
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 <account UID>/<platform work ID>.<image extension> 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
|
|
}
|