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. */
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 {
+18 -1
View File
@@ -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<App>({ 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<App>({ 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 })
}
+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 () => {
// Seed an image to cheer.
// 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. */
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<Feed>(`${h.api}/api/images/v1/slideshow`)
const d = await call<Feed>(`${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}`}
</span>
)}
{/* 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 && (
<span className="steer">
<button onClick={() => step(-1)} aria-label="Previous photo">