From 4111bc49aac3a86fa722ae30c1e42ec65b64da00 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 5 Aug 2026 17:01:45 -0400 Subject: [PATCH] limit slideshow to 10 by default --- apps/api/src/images-db.ts | 11 +++++++++-- apps/api/src/routes/images.ts | 19 ++++++++++++++++++- apps/api/src/test/integration/api.test.ts | 23 +++++++++++++++++++++++ apps/www/src/client/App.tsx | 16 ++++++++++++---- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts index 4a24c83..7d6359a 100644 --- a/apps/api/src/images-db.ts +++ b/apps/api/src/images-db.ts @@ -299,8 +299,15 @@ export function toImagesPlayer(img: SavedImage): ImagesPlayer { } } -/** Default number of recent images the slideshow feed returns. */ -export const SLIDESHOW_LIMIT = 130 +/** How many recent images the slideshow feed returns when the caller doesn't say. */ +export const SLIDESHOW_LIMIT = 10 + +/** + * The most a caller can ask the slideshow feed for. The endpoint is public and + * unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a + * scan of the whole image table plus the two batched joins behind it. + */ +export const SLIDESHOW_MAX_LIMIT = 100 /** The slideshow projection of an image — creator username + room name joined in. */ export interface SlideshowImage { diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index c7362b6..34b3f96 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -13,6 +13,8 @@ import { getSlideshowImages, SavedImageType, setImageCheer, + SLIDESHOW_LIMIT, + SLIDESHOW_MAX_LIMIT, toImagesPlayer, } from '../images-db' import { @@ -312,6 +314,10 @@ export const imageRoutes = new Hono({ strict: false }) // creator's username and room name. Public (no auth): it only surfaces already-public // images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`, // where ValidTill is a short (2-minute) cache hint the client refreshes against. + // Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and + // unauthenticated, so an unclamped `take` would let anyone ask for the whole image + // table — and the callers that rotate one photo at a time (the website's hero) don't + // want a long feed anyway. .get( '/api/images/v1/slideshow', describeRoute({ @@ -323,10 +329,21 @@ export const imageRoutes = new Hono({ strict: false }) 'Deliberately public — it surfaces only already-public images and backs the ' + 'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' + 'client refreshes against.', + parameters: [ + intQuery( + 'take', + `How many photos to return (default ${SLIDESHOW_LIMIT}, capped at ${SLIDESHOW_MAX_LIMIT})` + ), + ], responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') }, }), async (c) => { - const Images = await getSlideshowImages(c.env.DB) + // Junk, zero and negative takes fall back to the default rather than 400ing or + // serving an empty stage — the caller is a homepage, and no photos reads as the + // server being down. + const asked = Number.parseInt(c.req.query('take') ?? '', 10) + const take = asked > 0 ? Math.min(asked, SLIDESHOW_MAX_LIMIT) : SLIDESHOW_LIMIT + const Images = await getSlideshowImages(c.env.DB, take) const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString() return c.json({ Images, ValidTill }) } diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 066967b..78daa30 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1531,6 +1531,29 @@ describe('images', () => { }) }) + // The feed is public and unauthenticated, so `take` is clamped rather than trusted: + // without the cap a single anonymous request could pull the whole image table through + // the two joins behind it. + test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => { + // 120 public ShareCamera photos — more than both the default and the cap. + for (let i = 0; i < 120; i++) { + await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 }) + } + const feed = async (query: string) => { + const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`) + expect(res.status).toBe(200) + return ((await res.json()) as { Images: unknown[] }).Images.length + } + + expect(await feed('')).toBe(10) + expect(await feed('?take=25')).toBe(25) + expect(await feed('?take=500')).toBe(100) + // Junk and non-positive takes fall back rather than erroring or emptying the stage. + expect(await feed('?take=0')).toBe(10) + expect(await feed('?take=-5')).toBe(10) + expect(await feed('?take=lots')).toBe(10) + }) + test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => { // Seed an image to cheer. // Its own player id: 700's photos are asserted on exactly in the player-list test. diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index eaf4849..c1dbb3d 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -481,6 +481,14 @@ function NavBar({ ) } +/** + * How many photos the hero asks the feed for. Explicit rather than left to the api's + * default, since the count is a design decision here: the stage rotates one photo every + * six seconds, so ten is a minute of it — long enough that a repeat visitor sees fresh + * photos, short enough that the arrows stay walkable and the payload stays small. + */ +const SLIDESHOW_TAKE = 10 + /** A recent public image plus who took it and where. */ interface Slide { url: string @@ -507,7 +515,7 @@ function useSlideshow(config: SiteConfig | undefined) { // would take the page down instead of leaving an empty stage behind the fold. void (async () => { const h = where() - const d = await call(`${h.api}/api/images/v1/slideshow`) + const d = await call(`${h.api}/api/images/v1/slideshow?take=${SLIDESHOW_TAKE}`) setSlides( (d.Images ?? []).map((i) => ({ url: `${h.img}/${i.ImageName}`, @@ -636,9 +644,9 @@ function Stage({ {slide.roomName && ` in ${slide.roomName}`} )} - {/* Arrows and a count, not a dot per photo: the feed runs to SLIDESHOW_LIMIT - (130) images, and a dot each is both unusable and wide enough to shove - the headline's half of the split off the page. */} + {/* Arrows and a count, not a dot per photo: a dot each is wide enough to + shove the headline's half of the split off the page, and it would have + to be rebuilt the moment SLIDESHOW_TAKE grows. */} {count > 1 && (