[api] only show public images in slideshow

This commit is contained in:
Devin Zuczek
2026-09-29 23:09:36 -04:00
parent e8dcaa3051
commit ef5bfece59
3 changed files with 42 additions and 8 deletions
+5 -4
View File
@@ -468,10 +468,11 @@ export const imageRoutes = new Hono<App>({ strict: false })
} }
) )
// Global slideshow feed — the most recent publicly-listable ShareCamera photos // Global slideshow feed — the most recent public ShareCamera photos (Accessibility 1,
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its // Type 1) across all rooms, newest first, each joined to its creator's username and
// creator's username and room name. Public (no auth): it only surfaces already-public // room name. Public (no auth): it only surfaces already-public images and backs the
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`, // anonymous homepage slideshow. Private photos (Accessibility 0) never appear — the
// feed is anonymous, so there is no viewer it could be unlocked for. 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 // 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 // unauthenticated, so an unclamped `take` would let anyone ask for the whole image
+31
View File
@@ -6550,6 +6550,37 @@ describe('images', () => {
}) })
}) })
// The feed is anonymous, so a private photo (Accessibility 0) must never reach it —
// there is no viewer it could be unlocked for. It once accepted 0 alongside 1.
test('GET /api/images/v1/slideshow leaves private photos out', async () => {
const pub = await createImage(env.DB, {
imageName: 'slidepublic.jpg',
playerId: 42,
accessibility: 1,
})
const priv = await createImage(env.DB, {
imageName: 'slideprivate.jpg',
playerId: 42,
accessibility: 0,
})
// A non-photo (room thumbnail) that happens to be public stays out too.
const thumb = await createImage(env.DB, {
imageName: 'slidethumb.jpg',
playerId: 42,
type: 3,
accessibility: 1,
})
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow?take=100`)
expect(res.status).toBe(200)
const ids = ((await res.json()) as { Images: Array<{ SavedImageId: number }> }).Images.map(
(i) => i.SavedImageId
)
expect(ids).toContain(pub.Id)
expect(ids).not.toContain(priv.Id)
expect(ids).not.toContain(thumb.Id)
})
// The feed is public and unauthenticated, so `take` is clamped rather than trusted: // 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 // without the cap a single anonymous request could pull the whole image table through
// the two joins behind it. // the two joins behind it.
+6 -4
View File
@@ -518,9 +518,11 @@ async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number,
} }
/** /**
* The global slideshow feed — the most recent publicly-listable ShareCamera photos * The global slideshow feed — the most recent PUBLIC ShareCamera photos across all rooms
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`. * (Accessibility 1, Type 1), newest first, capped at `limit`. Accessibility 0 is a
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each * private photo — the same bar every other public image read here applies — and the
* feed is anonymous, so it must never surface one. Only ShareCamera images are surfaced
* (not room/profile/invention thumbnails). Each
* row is joined to its creator's username and (if any) its room's name. Returns the * row is joined to its creator's username and (if any) its room's name. Returns the
* projected SlideshowImage shape. Usernames/room names are resolved in two batched * projected SlideshowImage shape. Usernames/room names are resolved in two batched
* lookups to avoid an N+1 across the (at most `limit`) images. * lookups to avoid an N+1 across the (at most `limit`) images.
@@ -532,7 +534,7 @@ export async function getSlideshowImages(
const { results } = await db const { results } = await db
.prepare( .prepare(
`SELECT data FROM image `SELECT data FROM image
WHERE json_extract(data, '$.Accessibility') IN (0, 1) WHERE json_extract(data, '$.Accessibility') = 1
AND json_extract(data, '$.Type') = ?1 AND json_extract(data, '$.Type') = ?1
ORDER BY id DESC LIMIT ?2` ORDER BY id DESC LIMIT ?2`
) )