Validate decoded photo size and require a photos row to serve
CI / ci (push) Successful in 1m37s
CI / ci (pull_request) Successful in 1m37s

decodePhoto checked the GIF logical screen size, but gif.Decode returns
the first frame, which may be smaller; a 400x400 screen with a 1x1 first
frame passed and was scaled. The short-side check now measures the
decoded image's bounds. The pixel cap still comes from the header, before
decoding.

OpenPhoto served any file whose name was a canonical ULID and stored
size, with no photos row required. It now looks the id up with the new
GetPhoto query and reports an id with no row as fs.ErrNotExist, so
GET /media/photos/{id}-{size}.jpg is 404 for it even when a file exists.
OpenPhoto takes the request context for the lookup.

The media test fixture records a photos row for its served photo and
adds an orphan photo whose files exist without a row; both its sizes
must be 404.

Maestro task: 8a432108-d3ba-4013-ae8c-11963981d641

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 14:10:54 -04:00
co-authored by Claude Opus 5.5
parent 0347e11b7d
commit 70f545152a
8 changed files with 119 additions and 30 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"errors"
"io/fs"
"log/slog"
@@ -20,7 +21,7 @@ const photoCacheControl = "private, max-age=31536000, immutable"
// PhotoFiles opens a cached photo's file at one of its stored sizes. An
// unknown photo or size is an error matching fs.ErrNotExist.
type PhotoFiles interface {
OpenPhoto(id string, size int) (*os.File, error)
OpenPhoto(ctx context.Context, id string, size int) (*os.File, error)
}
// getPhoto answers GET /media/photos/{id}-{size}.jpg with the stored JPEG.
@@ -32,7 +33,7 @@ func getPhoto(logger *slog.Logger, photos PhotoFiles) http.HandlerFunc {
notFound(w, r)
return
}
f, err := photos.OpenPhoto(id, size)
f, err := photos.OpenPhoto(r.Context(), id, size)
if errors.Is(err, fs.ErrNotExist) {
notFound(w, r)
return
+35 -14
View File
@@ -10,39 +10,58 @@ import (
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// newMediaRouter returns a router over a photo cache whose data directory
// holds both sizes of one stored photo, and that photo's id. Each size's
// file holds its size as text.
func newMediaRouter(t *testing.T) (http.Handler, string) {
// holds both sizes of two photos, the id of the one with a photos row, and
// the id of the orphan without one. Each size's file holds its size as
// text.
func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Helper()
dataDir := t.TempDir()
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), newTestStore(t), dataDir)
st := newTestStore(t)
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), st, dataDir)
if err != nil {
t.Fatal(err)
}
id := store.NewID()
for _, size := range []string{"400", "1200"} {
p := filepath.Join(dataDir, "media", "photos", id+"-"+size+".jpg")
if err := os.WriteFile(p, []byte("jpeg "+size), 0o600); err != nil {
t.Fatal(err)
id, orphan = store.NewID(), store.NewID()
for _, stem := range []string{id, orphan} {
for _, size := range []string{"400", "1200"} {
p := filepath.Join(dataDir, "media", "photos", stem+"-"+size+".jpg")
if err := os.WriteFile(p, []byte("jpeg "+size), 0o600); err != nil {
t.Fatal(err)
}
}
}
now := store.Now().String()
if _, err := db.New(st.Writer).CreatePhoto(t.Context(), db.CreatePhotoParams{
ID: id,
SourceUrl: "https://example.com/a.jpg",
LocalPath: "media/photos/" + id + "-1200.jpg",
Width: 1600,
Height: 1200,
Sha256: "sha",
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r, id
return r, id, orphan
}
func TestGetPhotoServesEachSize(t *testing.T) {
t.Parallel()
router, id := newMediaRouter(t)
router, id, _ := newMediaRouter(t)
for _, size := range []string{"400", "1200"} {
req := httptest.NewRequest(http.MethodGet, "/media/photos/"+id+"-"+size+".jpg", nil)
req.AddCookie(validSessionCookie)
@@ -66,7 +85,7 @@ func TestGetPhotoServesEachSize(t *testing.T) {
func TestGetPhotoRequiresSession(t *testing.T) {
t.Parallel()
router, id := newMediaRouter(t)
router, id, _ := newMediaRouter(t)
req := httptest.NewRequest(http.MethodGet, "/media/photos/"+id+"-400.jpg", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -84,10 +103,12 @@ func TestGetPhotoRequiresSession(t *testing.T) {
func TestGetPhotoUnknownNotFound(t *testing.T) {
t.Parallel()
router, id := newMediaRouter(t)
router, id, orphan := newMediaRouter(t)
for _, name := range []string{
store.NewID() + "-400.jpg", // no such photo
orphan + "-400.jpg", // file stored, no photos row
id + "-800.jpg", // size not stored
orphan + "-1200.jpg",
id + "-0400.jpg",
id + "-+400.jpg",
id + "-400.png",