Files
creator-hub/internal/creator/covers.go
T

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
}