diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 1da30bb..80e2695 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -73,6 +73,17 @@ const typeFolder: Record = { */ const PHOTO_TAGGING_KEY = 'playerPhotoTaggingSetting' +/** + * The owner's-view clause the player photo lists share in their docs. The lists are + * public, but they read a Bearer token if one is sent, and the one player it can unlock + * anything for is the player whose list it is. + */ +const OWN_VIEW_NOTE = + 'Public, but reads a Bearer token if one is sent: a player asking for THEIR OWN list ' + + 'gets their private photos in it too (`Accessibility` 0 — that is where the photos they ' + + 'have not shared yet live). Any other player’s token, no token, or an invalid one serves ' + + 'the public list; none of those is a 401.' + /** * The preference a player has before they have ever set one. The value is an opaque enum * ordinal to this server (see `PhotoTaggingSettingRequest`), and 0 is what an unset .NET @@ -373,15 +384,22 @@ export const imageRoutes = new Hono({ strict: false }) // A player's photos — the public images that player has taken, newest first. // Paginated via skip/take (take defaults to 100). Returns a bare array of the // client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type). + // + // Public, but a token is READ if one is sent: a player asking for their own list gets + // their private photos in it too (the profile they open on themselves is where those + // live), while anyone else's token — or none — serves the public list. A bad token is + // not a 401 here; it just reads as nobody. .get( '/api/images/v4/player/:playerId{[0-9]+}', describeRoute({ tags: ['Images'], summary: 'A player’s photos', description: - 'The public images that player has taken, newest first. Serves the client’s ' + + 'The public camera photos (`SavedImageType` 1) that player has taken, newest first — ' + + 'not the outfit, room, invention or profile thumbnails filed under them. Serves the client’s ' + '`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' + - '— the raw `SavedImage` renders blank thumbnails here.', + '— the raw `SavedImage` renders blank thumbnails here.\n\n' + + OWN_VIEW_NOTE, parameters: [idParam('playerId', 'Account id'), ...pageParams(100)], responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') }, }), @@ -389,7 +407,8 @@ export const imageRoutes = new Hono({ strict: false }) const playerId = Number.parseInt(c.req.param('playerId'), 10) const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take) + const viewer = await authedId(c) + const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take, viewer) return c.json(images.map(toImagesPlayer)) } ) @@ -401,7 +420,9 @@ export const imageRoutes = new Hono({ strict: false }) describeRoute({ tags: ['Images'], summary: 'A player’s photos, sortable', - description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.', + description: + 'v4 plus a `sort` option. Same `ImagesPlayer` projection, and the same owner’s view — ' + + 'see the notes on v4.', parameters: [ idParam('playerId', 'Account id'), intQuery('sort', '1 = most cheered; anything else = newest first'), @@ -414,7 +435,8 @@ export const imageRoutes = new Hono({ strict: false }) const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take) + const viewer = await authedId(c) + const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take, viewer) return c.json(images.map(toImagesPlayer)) } ) @@ -428,8 +450,11 @@ export const imageRoutes = new Hono({ strict: false }) tags: ['Images'], summary: 'A player’s photo feed', description: - 'The public images they took PLUS the ones they are tagged in, newest first — the ' + - 'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.', + 'The public camera photos they took PLUS the ones they are tagged in, newest first — the ' + + 'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.\n\n' + + OWN_VIEW_NOTE + + ' A photo someone else took and tagged them in stays public-only either way: its ' + + 'privacy is the taker’s.', parameters: [idParam('playerId', 'Account id'), ...pageParams(100)], responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') }, }), @@ -437,7 +462,8 @@ export const imageRoutes = new Hono({ strict: false }) const playerId = Number.parseInt(c.req.param('playerId'), 10) const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - const images = await getPlayerFeed(c.env.DB, playerId, skip, take) + const viewer = await authedId(c) + const images = await getPlayerFeed(c.env.DB, playerId, skip, take, viewer) return c.json(images.map(toImagesPlayer)) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 14fdf8a..ad82682 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -6845,6 +6845,10 @@ describe('images', () => { }), // Unrelated to 700 → in neither. seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }), + // 700's outfit thumbnail: an image under their id, but not a photo → in neither, + // not even in their own view (207 is private, as the client stores such things). + seed({ Id: 200, PlayerId: 700, Type: 2, CreatedAt: '2026-06-01T00:00:00.000Z' }), + seed({ Id: 207, PlayerId: 700, Type: 3, Accessibility: 0 }), ]) // The lists serve the client's ImagesPlayer projection: the id and type are @@ -6896,6 +6900,44 @@ describe('images', () => { expect( await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json() ).toEqual([]) + + // The owner's view: 700 asking for their own list gets the private 203 too, on v4, + // v5 and the feed alike. 203 has the seed's default CreatedAt (January), so it sorts + // last — the private photo takes its place in the order, it isn't pinned anywhere. + const own = await bearer('700') + for (const path of ['v4/player/700', 'v5/player/700?sort=0']) { + const list = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/${path}`, { headers: own }) + ).json()) as Array + expect(list.map((i) => i.SavedImageId)).toEqual([202, 201, 203]) + expect(list[2]?.Accessibility).toBe(0) + } + const ownFeed = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own }) + ).json()) as ImagesPlayer[] + expect(ownFeed.map((i) => i.SavedImageId)).toEqual([204, 202, 201, 203]) + + // Being tagged in someone else's private photo does NOT surface it in the feed — + // the taker decides its privacy, not the tag. + await seed({ Id: 206, PlayerId: 999, TaggedPlayerIds: [700], Accessibility: 0 }).run() + const ownFeedAgain = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own }) + ).json()) as ImagesPlayer[] + expect(ownFeedAgain.map((i) => i.SavedImageId)).not.toContain(206) + + // Anyone else's token — a friend, staff — is the public list, same as no token. + const other = await bearer('999', ['developer', 'moderator']) + const asOther = (await ( + await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, { headers: other }) + ).json()) as ImagesPlayer[] + expect(asOther.map((i) => i.SavedImageId)).toEqual([202, 201]) + + // A garbage token isn't a 401 on a public list; it reads as nobody. + const junk = await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, { + headers: { Authorization: 'Bearer not.a.jwt' }, + }) + expect(junk.status).toBe(200) + expect(((await junk.json()) as ImagesPlayer[]).map((i) => i.SavedImageId)).toEqual([202, 201]) }) }) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 2c73b5c..4754f7f 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -804,12 +804,17 @@ interface PublicAccount { createdAt: string } -/** A player's public photo, from the `ImagesPlayer` projection. */ +/** + * A player's photo, from the `ImagesPlayer` projection. Public unless the list was the + * viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here — + * `Accessibility` is what tells the grid to mark it. + */ interface PublicPhoto { SavedImageId: number ImageName: string CreatedAt: string CheerCount: number + Accessibility: number } /** A player's public room — the narrow shape `/rooms/createdby/:id` serves. */ @@ -833,8 +838,15 @@ const fetchPublicAccount = (username: string): Promise => (matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null ) -const fetchPublicPhotos = (accountId: number): Promise => - call(`${where().api}/api/images/v4/player/${accountId}`) +/** + * The photos a player has taken. Sent WITH the session token when there is one: `api` + * reads it and, only when the list is the caller's own, includes the photos they keep + * private — so a signed-in player sees all of theirs, on `/account` and on their own + * `/u/`, while everyone else's profile is the public list either way. Nothing + * here decides who the viewer is; the worker does, from the token. + */ +const fetchPlayerPhotos = (accountId: number): Promise => + call(`${where().api}/api/images/v4/player/${accountId}`, { authed: true }) const fetchPublicRooms = (accountId: number): Promise => call(`${where().rooms}/rooms/ownedby/${accountId}`) @@ -849,19 +861,27 @@ async function fetchPublicRoom(roomId: number): Promise { } /** - * The public photos taken in a room. The feed serves the raw `SavedImage` record (`Id`, - * not `SavedImageId` — see the api worker's note on why the two lists differ), so it's - * narrowed here to the shape the photo grid draws. + * The public photos taken in a room. Asked for with `filter=1` (camera photos), as the + * game asks: without it the feed also carries the room's own thumbnail. The feed serves + * the raw `SavedImage` record (`Id`, not `SavedImageId` — see the api worker's note on + * why the two lists differ), so it's narrowed here to the shape the photo grid draws. */ const fetchRoomPhotos = (roomId: number): Promise => - call>( - `${where().api}/api/images/v4/room/${roomId}` - ).then((images) => + call< + Array<{ + Id: number + ImageName: string + CreatedAt: string + CheerCount: number + Accessibility: number + }> + >(`${where().api}/api/images/v4/room/${roomId}?filter=1`).then((images) => images.map((i) => ({ SavedImageId: i.Id, ImageName: i.ImageName, CreatedAt: i.CreatedAt, CheerCount: i.CheerCount, + Accessibility: i.Accessibility, })) ) @@ -997,7 +1017,9 @@ function PhotoGrid({ photos }: { photos: PublicPhoto[] }) { key={p.SavedImageId} className="photo-button" onClick={() => setOpen(p)} - aria-label="View photo" + aria-label={ + p.Accessibility === Accessibility.Public ? 'View photo' : 'View private photo' + } > + {/* Only the owner's own list ever carries a private photo (see + fetchPlayerPhotos), so this is the mark that tells them which of theirs + nobody else can see. */} + {p.Accessibility !== Accessibility.Public && ( + Private + )} ))} @@ -1060,7 +1088,7 @@ function PlayerPage({ void fetchPublicRooms(a.accountId) .then(setRooms) .catch(() => setRooms([])) - void fetchPublicPhotos(a.accountId) + void fetchPlayerPhotos(a.accountId) .then(setPhotos) .catch(() => setPhotos([])) } @@ -3178,18 +3206,20 @@ function MyRooms({ navigate }: { navigate: Navigate }) { } /** - * The photos the signed-in player has taken — the same grid their public profile shows. + * The photos the signed-in player has taken — the same grid their profile shows, private + * ones included. * - * Public photos only, because it reads the same list the profile does - * (`/api/images/v4/player/:id`, which serves nothing private). Unlike "My rooms" there is - * no owner's view behind it yet: a photo kept private in game doesn't appear here. + * It reads the same list the profile does (`/api/images/v4/player/:id`); what makes it the + * owner's view is the session token going with the request, which `api` matches against + * the id (see fetchPlayerPhotos). The private ones are marked in the grid; there is no + * way to change a photo's privacy here yet — that's still done in game. */ function MyPhotos({ accountId }: { accountId: number }) { const [photos, setPhotos] = useState(null) const [error, setError] = useState('') useEffect(() => { - void fetchPublicPhotos(accountId) + void fetchPlayerPhotos(accountId) .then(setPhotos) .catch((e) => setError(e instanceof Error ? e.message : String(e))) }, [accountId]) @@ -3197,15 +3227,16 @@ function MyPhotos({ accountId }: { accountId: number }) { return (

My photos

-

The public photos you've taken in game, newest first.

+

+ The photos you've taken in game, newest first. Ones you keep private are marked; only + you can see them here. +

{error ? (

{error}

) : photos === null ? (

Loading…

) : photos.length === 0 ? ( -

- No public photos yet. Photos you take in game and share publicly show up here. -

+

No photos yet. Photos you take in game show up here.

) : ( )} diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 8006d1a..393c6f5 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -1863,6 +1863,7 @@ button[type='submit']:disabled { /* The thumbnail's click target: a real button, for the keyboard, with none of its looks. */ .photo-button { display: block; + position: relative; padding: 0; border: 0; border-radius: 9px; @@ -1870,6 +1871,17 @@ button[type='submit']:disabled { cursor: zoom-in; } +/* The Private mark on one of the owner's own photos, pinned to the thumbnail's corner. + Same pill as a room's visibility badge, on a solid background so it reads over the + picture. */ +.photo-private { + position: absolute; + top: 6px; + left: 6px; + background: var(--surface); + pointer-events: none; +} + .photo-thumb { display: block; width: 100%; diff --git a/packages/domain/src/images-db.ts b/packages/domain/src/images-db.ts index 48857ce..4829395 100644 --- a/packages/domain/src/images-db.ts +++ b/packages/domain/src/images-db.ts @@ -233,9 +233,10 @@ const IMAGE_ID_LIMIT = 100 * for; an id with no record, or one that isn't public, is simply absent rather than a * hole in the list. * - * Public-only, like every other image read here ({@link getImagesByPlayer}, - * {@link getImagesByRoom}). Image ids are sequential, so serving whatever an id names - * would make a private photo readable by anyone who counts. + * Public-only, like the room feed ({@link getImagesByRoom}) and the player lists when + * anyone but the owner reads them ({@link getImagesByPlayer}). Image ids are sequential, + * so serving whatever an id names would make a private photo readable by anyone who + * counts. */ export async function getImagesByIds(db: D1Database, ids: number[]): Promise { if (ids.length === 0) return [] @@ -333,7 +334,28 @@ const newestFirst = (a: SavedImage, b: SavedImage) => b.CreatedAt.localeCompare(a.CreatedAt) || b.Id - a.Id /** - * The public images a player has taken — their photo list, newest first. + * Whether a read of `playerId`'s photos should include the ones they keep private: + * only when the viewer IS that player. Nobody else — not a friend, not a co-owner, + * not staff — sees a private photo through the player lists; the owner's own view is + * the one exception to "every image read here is public-only", and it exists because + * the profile a player opens on themselves (in game and on the website) is where they + * find the photos they haven't shared yet. + */ +const ownView = (playerId: number, viewerId: number | null | undefined): boolean => + viewerId != null && viewerId === playerId + +/** + * Whether an image belongs in a player's photo list: a camera photo, not one of the + * thumbnails filed under the same player (their outfits, room and invention pictures, + * profile shots — every other {@link SavedImageType}). Those are stored as images too, + * and until this was applied they surfaced in the list beside the real photos. + */ +const isPhoto = (img: SavedImage): boolean => img.Type === SavedImageType.ShareCamera + +/** + * The camera photos a player has taken ({@link isPhoto}) — their photo list, newest + * first. Public only, unless `viewerId` is the player themself (see {@link ownView}), + * in which case their private photos are in the list too. * Paginated via skip/take; returns a bare array of SavedImage. Uses the * player_id index; the per-player set is small, so filtering/sorting is in memory. */ @@ -342,15 +364,17 @@ export async function getImagesByPlayer( playerId: number, sort: number, skip: number, - take: number + take: number, + viewerId?: number | null ): Promise { const { results } = await db .prepare('SELECT data FROM image WHERE player_id = ?1') .bind(playerId) .all() + const own = ownView(playerId, viewerId) return results .map((r) => JSON.parse(r.data) as SavedImage) - .filter((img) => img.Accessibility === 1) + .filter((img) => isPhoto(img) && (own || img.Accessibility === 1)) .sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst) .slice(skip, skip + take) } @@ -535,16 +559,22 @@ export async function getSlideshowImages( } /** - * A player's photo feed — the public images they took plus the ones they're - * tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a + * A player's photo feed — the public camera photos ({@link isPhoto}) they took plus the + * ones they're tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a * bare array of SavedImage. The tagged-in match uses json_each over the stored * TaggedPlayerIds array (there's no index for it). + * + * When `viewerId` is the player themself ({@link ownView}) the photos they TOOK come + * through private ones included, as on {@link getImagesByPlayer}. A photo someone + * else took and tagged them in stays public-only either way: its privacy is the + * taker's to decide, and being tagged in it doesn't grant a view of it. */ export async function getPlayerFeed( db: D1Database, playerId: number, skip: number, - take: number + take: number, + viewerId?: number | null ): Promise { const { results } = await db .prepare( @@ -554,9 +584,11 @@ export async function getPlayerFeed( ) .bind(playerId) .all() + const own = ownView(playerId, viewerId) return results .map((r) => JSON.parse(r.data) as SavedImage) - .filter((img) => img.Accessibility === 1) + .filter(isPhoto) + .filter((img) => img.Accessibility === 1 || (own && img.PlayerId === playerId)) .sort(newestFirst) .slice(skip, skip + take) }