Files
silo-server/internal/api/handlers/collection_artwork.go
T
Silo Server Migration 9c3848d205 Port collection templates to bundled poster storage
- Store bundled template posters in public S3 when available
- Presign imported user collection posters in API responses
- Thread frontend assets into router and collection handlers
2026-05-23 20:26:05 -04:00

195 lines
5.8 KiB
Go

package handlers
import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"strings"
"github.com/Silo-Server/silo-server/internal/imageutil"
"github.com/Silo-Server/silo-server/internal/s3client"
)
// Shared artwork helpers used by both the admin library_collections handler
// and the user collections handler. The S3 prefix differs so the two
// namespaces don't collide.
const (
adminCollectionImagePrefix = "collection-images"
userCollectionImagePrefix = "user-collection-images"
collectionTemplateImageDir = "/images/collection-templates/"
collectionImageMaxBytes = 10 << 20 // 10 MB
)
// storeBundledCollectionPosterIfS3Configured stores a built-in collection
// template poster in S3 when public asset storage is configured. Non-S3
// installs and non-template paths keep the original persisted path.
func storeBundledCollectionPosterIfS3Configured(
ctx context.Context,
s3GP *s3client.Client,
frontendFS fs.FS,
collectionID, prefix, posterPath string,
) (storedPath, thumbhashStr string, stored bool, err error) {
posterPath = strings.TrimSpace(posterPath)
if s3GP == nil || !strings.HasPrefix(posterPath, collectionTemplateImageDir) {
return posterPath, "", false, nil
}
if frontendFS == nil {
return "", "", false, fmt.Errorf("frontend assets are not available")
}
assetPath := strings.TrimPrefix(posterPath, "/")
data, err := fs.ReadFile(frontendFS, assetPath)
if err != nil {
return "", "", false, fmt.Errorf("reading bundled poster %q: %w", posterPath, err)
}
storedPath, thumbhashStr, err = uploadCollectionImageVariants(ctx, s3GP, prefix, collectionID, "poster", data)
if err != nil {
return "", "", false, err
}
return storedPath, thumbhashStr, true, nil
}
// readCollectionImageMultipart reads a single image file from a multipart
// request, validating MIME type and size.
func readCollectionImageMultipart(r *http.Request, fieldName string) ([]byte, error) {
file, header, err := r.FormFile(fieldName)
if err != nil {
return nil, err
}
defer file.Close()
switch header.Header.Get("Content-Type") {
case "image/jpeg", "image/png", "image/webp":
default:
return nil, fmt.Errorf("unsupported image type: %s", header.Header.Get("Content-Type"))
}
if header.Size > collectionImageMaxBytes {
return nil, fmt.Errorf("file exceeds 10 MB limit")
}
data := make([]byte, header.Size)
if _, err := io.ReadFull(file, data); err != nil {
return nil, fmt.Errorf("reading file: %w", err)
}
return data, nil
}
// downloadCollectionImageURL fetches an image from an http(s) URL with size
// limits.
func downloadCollectionImageURL(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("image source URL must use http or https")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("downloading image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("image source returned status %d", resp.StatusCode)
}
if resp.ContentLength > collectionImageMaxBytes {
return nil, fmt.Errorf("image exceeds 10 MB limit")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, collectionImageMaxBytes+1))
if err != nil {
return nil, fmt.Errorf("reading image response: %w", err)
}
if len(data) > collectionImageMaxBytes {
return nil, fmt.Errorf("image exceeds 10 MB limit")
}
return data, nil
}
// uploadCollectionImageVariants generates resized variants for the given
// image bytes, uploads them under "{prefix}/{collectionID}/{imageType}/", and
// returns the S3 path of the original variant plus a thumbhash computed from
// the w300 variant.
func uploadCollectionImageVariants(
ctx context.Context,
s3GP *s3client.Client,
prefix, collectionID, imageType string,
fileData []byte,
) (s3Path, thumbhashStr string, err error) {
if s3GP == nil {
return "", "", fmt.Errorf("image upload requires configured S3 storage")
}
var widths []int
switch imageType {
case "poster":
widths = []int{500, 300}
case "backdrop":
widths = []int{1280, 300}
default:
return "", "", fmt.Errorf("invalid image type: %s", imageType)
}
result, err := imageutil.GenerateVariants(fileData, widths)
if err != nil {
return "", "", fmt.Errorf("generating image variants: %w", err)
}
bucket := s3GP.Bucket()
var w300Data []byte
for _, v := range result.Variants {
key := fmt.Sprintf("%s/%s/%s/%s%s", prefix, collectionID, imageType, v.Key, result.Ext)
if err := s3GP.PutObject(ctx, bucket, key, v.Data); err != nil {
return "", "", fmt.Errorf("uploading %s: %w", v.Key, err)
}
if v.Key == "w300" {
w300Data = v.Data
}
if v.Key == "original" {
s3Path = key
}
}
if len(w300Data) > 0 {
thumbhashStr, err = imageutil.Thumbhash(w300Data)
if err != nil {
return "", "", fmt.Errorf("computing thumbhash: %w", err)
}
}
return s3Path, thumbhashStr, nil
}
// removeCollectionImageVariants deletes every stored variant for the given
// collection / imageType under the supplied S3 prefix.
func removeCollectionImageVariants(
ctx context.Context,
s3GP *s3client.Client,
prefix, collectionID, imageType string,
) error {
if s3GP == nil {
return nil
}
p := fmt.Sprintf("%s/%s/%s/", prefix, collectionID, imageType)
keys, err := s3GP.ListObjects(ctx, s3GP.Bucket(), p)
if err != nil {
return fmt.Errorf("listing objects: %w", err)
}
for _, key := range keys {
if err := s3GP.DeleteObject(ctx, s3GP.Bucket(), key); err != nil {
return fmt.Errorf("deleting %s: %w", key, err)
}
}
return nil
}