limit slideshow to 10 by default

This commit is contained in:
Devin Zuczek
2026-08-05 17:01:45 -04:00
parent f6561f1ec9
commit 4111bc49aa
4 changed files with 62 additions and 7 deletions
+9 -2
View File
@@ -299,8 +299,15 @@ export function toImagesPlayer(img: SavedImage): ImagesPlayer {
} }
} }
/** Default number of recent images the slideshow feed returns. */ /** How many recent images the slideshow feed returns when the caller doesn't say. */
export const SLIDESHOW_LIMIT = 130 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. */ /** The slideshow projection of an image — creator username + room name joined in. */
export interface SlideshowImage { export interface SlideshowImage {
+18 -1
View File
@@ -13,6 +13,8 @@ import {
getSlideshowImages, getSlideshowImages,
SavedImageType, SavedImageType,
setImageCheer, setImageCheer,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImagesPlayer, toImagesPlayer,
} from '../images-db' } from '../images-db'
import { import {
@@ -312,6 +314,10 @@ export const imageRoutes = new Hono<App>({ strict: false })
// creator's username and room name. Public (no auth): it only surfaces already-public // creator's username and room name. Public (no auth): it only surfaces already-public
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`, // images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against. // 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( .get(
'/api/images/v1/slideshow', '/api/images/v1/slideshow',
describeRoute({ describeRoute({
@@ -323,10 +329,21 @@ export const imageRoutes = new Hono<App>({ strict: false })
'Deliberately public — it surfaces only already-public images and backs the ' + 'Deliberately public — it surfaces only already-public images and backs the ' +
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' + 'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
'client refreshes against.', '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') }, responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
}), }),
async (c) => { 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() const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill }) return c.json({ Images, ValidTill })
} }
+23
View File
@@ -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 () => { test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
// Seed an image to cheer. // Seed an image to cheer.
// Its own player id: 700's photos are asserted on exactly in the player-list test. // Its own player id: 700's photos are asserted on exactly in the player-list test.
+12 -4
View File
@@ -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. */ /** A recent public image plus who took it and where. */
interface Slide { interface Slide {
url: string 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. // would take the page down instead of leaving an empty stage behind the fold.
void (async () => { void (async () => {
const h = where() const h = where()
const d = await call<Feed>(`${h.api}/api/images/v1/slideshow`) const d = await call<Feed>(`${h.api}/api/images/v1/slideshow?take=${SLIDESHOW_TAKE}`)
setSlides( setSlides(
(d.Images ?? []).map((i) => ({ (d.Images ?? []).map((i) => ({
url: `${h.img}/${i.ImageName}`, url: `${h.img}/${i.ImageName}`,
@@ -636,9 +644,9 @@ function Stage({
{slide.roomName && ` in ${slide.roomName}`} {slide.roomName && ` in ${slide.roomName}`}
</span> </span>
)} )}
{/* Arrows and a count, not a dot per photo: the feed runs to SLIDESHOW_LIMIT {/* Arrows and a count, not a dot per photo: a dot each is wide enough to
(130) images, and a dot each is both unusable and wide enough to shove shove the headline's half of the split off the page, and it would have
the headline's half of the split off the page. */} to be rebuilt the moment SLIDESHOW_TAKE grows. */}
{count > 1 && ( {count > 1 && (
<span className="steer"> <span className="steer">
<button onClick={() => step(-1)} aria-label="Previous photo"> <button onClick={() => step(-1)} aria-label="Previous photo">