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
This commit is contained in:
Silo Server Migration
2026-05-23 20:26:05 -04:00
parent 70b8f6ca2c
commit 01b9709e4f
6 changed files with 395 additions and 4 deletions
+3 -2
View File
@@ -1357,15 +1357,16 @@ func main() {
compatServer.SessionStore().DeleteByUserID(userID)
}
}
router := api.NewRouter(deps)
// Step 8: Expose Prometheus metrics endpoint (not behind auth).
distFS, fsErr := fs.Sub(siloweb.DistFS, "dist")
if fsErr != nil {
log.Fatalf("failed to create frontend FS: %v", fsErr)
}
deps.FrontendFS = distFS
server.WebDistFS = distFS
router := api.NewRouter(deps)
// Step 8: Expose Prometheus metrics endpoint (not behind auth).
metricsMux := http.NewServeMux()
metricsMux.Handle("/metrics", promhttp.Handler())
metricsMux.Handle("/api/", router)
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"strings"
@@ -18,10 +19,41 @@ import (
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) {
@@ -0,0 +1,175 @@
package handlers
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"testing/fstest"
"github.com/Silo-Server/silo-server/internal/s3client"
)
type collectionArtworkS3Recorder struct {
server *httptest.Server
mu sync.Mutex
puts []string
}
func newCollectionArtworkS3Recorder(t *testing.T) *collectionArtworkS3Recorder {
t.Helper()
recorder := &collectionArtworkS3Recorder{}
recorder.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
if r.Method == http.MethodPut {
recorder.mu.Lock()
recorder.puts = append(recorder.puts, r.URL.Path)
recorder.mu.Unlock()
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(recorder.server.Close)
return recorder
}
func (r *collectionArtworkS3Recorder) client() *s3client.Client {
return s3client.NewClient(s3client.BucketConfig{
Endpoint: r.server.URL,
Region: "us-east-1",
Bucket: "public-assets",
AccessKey: "test",
SecretKey: "test",
PathStyle: true,
})
}
func (r *collectionArtworkS3Recorder) putPaths() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.puts))
copy(out, r.puts)
return out
}
func TestStoreBundledCollectionPosterIfS3Configured_NoS3KeepsPath(t *testing.T) {
path := "/images/collection-templates/template.jpg"
gotPath, gotThumbhash, stored, err := storeBundledCollectionPosterIfS3Configured(
context.Background(),
nil,
fstest.MapFS{},
"collection-1",
adminCollectionImagePrefix,
path,
)
if err != nil {
t.Fatalf("storeBundledCollectionPosterIfS3Configured: %v", err)
}
if stored {
t.Fatal("stored = true, want false")
}
if gotPath != path {
t.Fatalf("path = %q, want %q", gotPath, path)
}
if gotThumbhash != "" {
t.Fatalf("thumbhash = %q, want empty", gotThumbhash)
}
}
func TestStoreBundledCollectionPosterIfS3Configured_IgnoresNonTemplatePath(t *testing.T) {
recorder := newCollectionArtworkS3Recorder(t)
path := "collection-images/existing/poster/original.webp"
gotPath, gotThumbhash, stored, err := storeBundledCollectionPosterIfS3Configured(
context.Background(),
recorder.client(),
fstest.MapFS{},
"collection-1",
adminCollectionImagePrefix,
path,
)
if err != nil {
t.Fatalf("storeBundledCollectionPosterIfS3Configured: %v", err)
}
if stored {
t.Fatal("stored = true, want false")
}
if gotPath != path {
t.Fatalf("path = %q, want %q", gotPath, path)
}
if gotThumbhash != "" {
t.Fatalf("thumbhash = %q, want empty", gotThumbhash)
}
if puts := recorder.putPaths(); len(puts) != 0 {
t.Fatalf("PUT paths = %#v, want none", puts)
}
}
func TestStoreBundledCollectionPosterIfS3Configured_UploadsTemplatePoster(t *testing.T) {
recorder := newCollectionArtworkS3Recorder(t)
frontendFS := fstest.MapFS{
"images/collection-templates/template.jpg": {
Data: testCollectionPosterJPEG(t),
},
}
gotPath, gotThumbhash, stored, err := storeBundledCollectionPosterIfS3Configured(
context.Background(),
recorder.client(),
frontendFS,
"collection-1",
adminCollectionImagePrefix,
"/images/collection-templates/template.jpg",
)
if err != nil {
t.Fatalf("storeBundledCollectionPosterIfS3Configured: %v", err)
}
if !stored {
t.Fatal("stored = false, want true")
}
if gotPath != "collection-images/collection-1/poster/original.webp" {
t.Fatalf("path = %q", gotPath)
}
if gotThumbhash == "" {
t.Fatal("thumbhash is empty")
}
want := map[string]bool{
"/public-assets/collection-images/collection-1/poster/original.webp": true,
"/public-assets/collection-images/collection-1/poster/w500.webp": true,
"/public-assets/collection-images/collection-1/poster/w300.webp": true,
}
puts := recorder.putPaths()
if len(puts) != len(want) {
t.Fatalf("PUT paths = %#v", puts)
}
for _, path := range puts {
if !want[path] {
t.Fatalf("unexpected PUT path %q in %#v", path, puts)
}
}
}
func testCollectionPosterJPEG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 32, 48))
for y := 0; y < 48; y++ {
for x := 0; x < 32; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 6), G: uint8(y * 4), B: 120, A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
+97 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"runtime/debug"
"slices"
@@ -41,6 +42,7 @@ type LibraryCollectionHandler struct {
presignTTL time.Duration
httpClient *http.Client
s3GP *s3client.Client
FrontendFS fs.FS
SectionRepo *sections.Repository
UserCollectionPool *pgxpool.Pool
GroupRepo *catalog.LibraryCollectionGroupRepository
@@ -110,6 +112,39 @@ func (h *LibraryCollectionHandler) SetupCollage() {
slog.Info("collage: auto-generation enabled")
}
func (h *LibraryCollectionHandler) storeBundledTemplatePoster(
ctx context.Context,
collectionID, posterPath string,
fromTemplate bool,
) (bool, string, string, error) {
storedPath, thumbhash, stored, err := storeBundledCollectionPosterIfS3Configured(
ctx,
h.s3GP,
h.FrontendFS,
collectionID,
adminCollectionImagePrefix,
posterPath,
)
if err != nil || !stored {
return false, storedPath, thumbhash, err
}
notAutoGenerated := false
input := catalog.UpdateLibraryCollectionInput{
ID: collectionID,
PosterURL: &storedPath,
PosterThumbhash: &thumbhash,
PosterAutoGenerated: &notAutoGenerated,
}
if fromTemplate {
input.PosterFromTemplate = &fromTemplate
}
if err := h.repo.Update(ctx, input); err != nil {
return false, "", "", fmt.Errorf("updating bundled poster path: %w", err)
}
return true, storedPath, thumbhash, nil
}
// GenerateCollectionPoster implements catalog.CollageGenerator.
// It fetches item poster images, composes a collage, and stores the result.
func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, collectionID string) error {
@@ -1107,11 +1142,16 @@ func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWr
return
}
posterStored, _, _, err := h.storeBundledTemplatePoster(r.Context(), collection.ID, req.PosterURL, false)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster")
return
}
if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
return
}
if r.MultipartForm != nil || strings.TrimSpace(req.PosterSourceURL) != "" || strings.TrimSpace(req.BackdropSourceURL) != "" {
if posterStored || r.MultipartForm != nil || strings.TrimSpace(req.PosterSourceURL) != "" || strings.TrimSpace(req.BackdropSourceURL) != "" {
collection, err = h.repo.GetByID(r.Context(), collection.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
@@ -1818,6 +1858,18 @@ func (h *LibraryCollectionHandler) ensureTemplatePoster(
if collection.PosterURL == posterPath && collection.PosterFromTemplate {
return
}
if stored, _, _, err := h.storeBundledTemplatePoster(ctx, collection.ID, posterPath, true); err != nil {
slog.Warn("failed to store template poster",
"collection_id", collection.ID,
"template_id", tmpl.ID,
"poster_path", posterPath,
"error", err,
)
return
} else if stored {
return
}
emptyThumbhash := ""
notAutoGenerated := false
fromTemplate := true
@@ -2405,6 +2457,16 @@ func (h *LibraryCollectionHandler) createMDBListCollection(
if err != nil {
return nil, fmt.Errorf("creating collection: %w", err)
}
if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil {
return nil, err
} else if stored {
collection.PosterURL = path
collection.PosterThumbhash = thumbhash
collection.PosterAutoGenerated = false
if req.PosterFromTemplate {
collection.PosterFromTemplate = true
}
}
return collection, nil
}
@@ -2455,6 +2517,16 @@ func (h *LibraryCollectionHandler) createTMDBCollection(
if err != nil {
return nil, fmt.Errorf("creating collection: %w", err)
}
if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil {
return nil, err
} else if stored {
collection.PosterURL = path
collection.PosterThumbhash = thumbhash
collection.PosterAutoGenerated = false
if req.PosterFromTemplate {
collection.PosterFromTemplate = true
}
}
return collection, nil
}
@@ -2514,6 +2586,16 @@ func (h *LibraryCollectionHandler) createTMDBFranchiseCollection(
if err != nil {
return nil, fmt.Errorf("creating collection: %w", err)
}
if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil {
return nil, err
} else if stored {
collection.PosterURL = path
collection.PosterThumbhash = thumbhash
collection.PosterAutoGenerated = false
if req.PosterFromTemplate {
collection.PosterFromTemplate = true
}
}
return collection, nil
}
@@ -2567,6 +2649,16 @@ func (h *LibraryCollectionHandler) createTMDBDiscoverCollection(
if err != nil {
return nil, fmt.Errorf("creating collection: %w", err)
}
if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil {
return nil, err
} else if stored {
collection.PosterURL = path
collection.PosterThumbhash = thumbhash
collection.PosterAutoGenerated = false
if req.PosterFromTemplate {
collection.PosterFromTemplate = true
}
}
return collection, nil
}
@@ -2758,6 +2850,10 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr
return
}
if _, _, _, err := h.storeBundledTemplatePoster(r.Context(), collection.ID, req.PosterURL, false); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster")
return
}
if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
return
@@ -1,9 +1,11 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"strings"
"time"
@@ -13,6 +15,7 @@ import (
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/collections/templates"
"github.com/Silo-Server/silo-server/internal/mdblist"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/usercollections"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -26,6 +29,9 @@ type UserCollectionImportHandler struct {
scheduler *usercollections.Scheduler
registry *templates.Registry
mdblist *mdblist.Client
s3GP *s3client.Client
frontendFS fs.FS
presignTTL time.Duration
}
func NewUserCollectionImportHandler(
@@ -34,6 +40,9 @@ func NewUserCollectionImportHandler(
scheduler *usercollections.Scheduler,
registry *templates.Registry,
mdblistClient *mdblist.Client,
s3GP *s3client.Client,
frontendFS fs.FS,
presignTTL time.Duration,
) *UserCollectionImportHandler {
if registry == nil {
registry = templates.Default
@@ -44,6 +53,9 @@ func NewUserCollectionImportHandler(
scheduler: scheduler,
registry: registry,
mdblist: mdblistClient,
s3GP: s3GP,
frontendFS: frontendFS,
presignTTL: presignTTL,
}
}
@@ -213,6 +225,11 @@ func (h *UserCollectionImportHandler) createImportedCollection(
return
}
if err := h.storeBundledTemplatePoster(r, store, collection, strings.TrimSpace(shared.PosterURL)); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster")
return
}
syncResult, updated, syncErr := h.sync.RunSync(r.Context(), store, collection)
if syncErr != nil {
// Persist failure state inline so the UI shows the error and the user
@@ -230,11 +247,75 @@ func (h *UserCollectionImportHandler) createImportedCollection(
}
writeJSON(w, http.StatusCreated, userImportResponse{
Collection: toCollectionResponse(*updated),
Collection: h.toCollectionResponse(r, *updated),
Sync: syncResult,
})
}
func (h *UserCollectionImportHandler) storeBundledTemplatePoster(
r *http.Request,
store userstore.UserStore,
collection *userstore.Collection,
posterPath string,
) error {
if collection == nil {
return nil
}
storedPath, thumbhash, stored, err := storeBundledCollectionPosterIfS3Configured(
r.Context(),
h.s3GP,
h.frontendFS,
collection.ID,
userCollectionImagePrefix,
posterPath,
)
if err != nil || !stored {
return err
}
if err := store.UpdateCollection(r.Context(), userstore.UpdateCollectionInput{
ID: collection.ID,
RequestProfileID: collection.CreatorProfileID,
PosterURL: &storedPath,
PosterThumbhash: &thumbhash,
}); err != nil {
return fmt.Errorf("persisting template poster: %w", err)
}
collection.PosterURL = storedPath
collection.PosterThumbhash = thumbhash
return nil
}
func (h *UserCollectionImportHandler) toCollectionResponse(r *http.Request, c userstore.Collection) collectionResponse {
resp := toCollectionResponse(c)
resp.PosterURL = h.presignCollectionPoster(r.Context(), c.PosterURL)
return resp
}
func (h *UserCollectionImportHandler) presignCollectionPoster(ctx context.Context, path string) string {
if path == "" {
return ""
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if strings.HasPrefix(path, "/") {
return path
}
if h.s3GP == nil {
return ""
}
ttl := h.presignTTL
if ttl <= 0 {
ttl = 4 * time.Hour
}
url, err := h.s3GP.PresignGetURL(ctx, h.s3GP.Bucket(), cardThumbnailPath(path), ttl)
if err != nil {
return ""
}
return url
}
func (h *UserCollectionImportHandler) HandleSync(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "id")
if collectionID == "" {
+6
View File
@@ -4,6 +4,7 @@ package api
import (
"context"
"errors"
"io/fs"
"log/slog"
"net/http"
"strconv"
@@ -69,6 +70,7 @@ type Dependencies struct {
BootstrapSensitiveValues map[string]string
AppContext context.Context
DB *pgxpool.Pool
FrontendFS fs.FS
S3Public *s3client.Client // public assets bucket client (may be nil)
S3Private *s3client.Client // private internal bucket client (may be nil)
S3UserDB *s3client.Client // user-db bucket client (may be nil)
@@ -843,6 +845,7 @@ func NewRouter(deps Dependencies) chi.Router {
nil,
deps.S3Public,
)
libraryCollectionHandler.FrontendFS = deps.FrontendFS
libraryCollectionHandler.Executor = &catalog.QueryExecutor{Pool: deps.DB}
libraryCollectionHandler.SectionRepo = sectionRepo
libraryCollectionHandler.UserCollectionPool = deps.DB
@@ -1298,6 +1301,9 @@ func NewRouter(deps Dependencies) chi.Router {
deps.UserCollectionScheduler,
nil,
deps.MDBListClient,
deps.S3Public,
deps.FrontendFS,
4*time.Hour,
)
}
r.Route("/collections", func(r chi.Router) {