mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] show private photos when logged in
This commit is contained in:
@@ -73,6 +73,17 @@ const typeFolder: Record<number, string> = {
|
|||||||
*/
|
*/
|
||||||
const PHOTO_TAGGING_KEY = 'playerPhotoTaggingSetting'
|
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
|
* 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
|
* ordinal to this server (see `PhotoTaggingSettingRequest`), and 0 is what an unset .NET
|
||||||
@@ -373,15 +384,22 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
// A player's photos — the public images that player has taken, newest first.
|
// 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
|
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
|
||||||
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
|
// 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(
|
.get(
|
||||||
'/api/images/v4/player/:playerId{[0-9]+}',
|
'/api/images/v4/player/:playerId{[0-9]+}',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Images'],
|
tags: ['Images'],
|
||||||
summary: 'A player’s photos',
|
summary: 'A player’s photos',
|
||||||
description:
|
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`) ' +
|
'`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)],
|
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
|
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
|
||||||
}),
|
}),
|
||||||
@@ -389,7 +407,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '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 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))
|
return c.json(images.map(toImagesPlayer))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -401,7 +420,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Images'],
|
tags: ['Images'],
|
||||||
summary: 'A player’s photos, sortable',
|
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: [
|
parameters: [
|
||||||
idParam('playerId', 'Account id'),
|
idParam('playerId', 'Account id'),
|
||||||
intQuery('sort', '1 = most cheered; anything else = newest first'),
|
intQuery('sort', '1 = most cheered; anything else = newest first'),
|
||||||
@@ -414,7 +435,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '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 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))
|
return c.json(images.map(toImagesPlayer))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -428,8 +450,11 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
tags: ['Images'],
|
tags: ['Images'],
|
||||||
summary: 'A player’s photo feed',
|
summary: 'A player’s photo feed',
|
||||||
description:
|
description:
|
||||||
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
|
'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.',
|
'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)],
|
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') },
|
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') },
|
||||||
}),
|
}),
|
||||||
@@ -437,7 +462,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '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 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))
|
return c.json(images.map(toImagesPlayer))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6845,6 +6845,10 @@ describe('images', () => {
|
|||||||
}),
|
}),
|
||||||
// Unrelated to 700 → in neither.
|
// Unrelated to 700 → in neither.
|
||||||
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
|
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
|
// The lists serve the client's ImagesPlayer projection: the id and type are
|
||||||
@@ -6896,6 +6900,44 @@ describe('images', () => {
|
|||||||
expect(
|
expect(
|
||||||
await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json()
|
await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json()
|
||||||
).toEqual([])
|
).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<ImagesPlayer & { Accessibility: number }>
|
||||||
|
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])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+51
-20
@@ -804,12 +804,17 @@ interface PublicAccount {
|
|||||||
createdAt: string
|
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 {
|
interface PublicPhoto {
|
||||||
SavedImageId: number
|
SavedImageId: number
|
||||||
ImageName: string
|
ImageName: string
|
||||||
CreatedAt: string
|
CreatedAt: string
|
||||||
CheerCount: number
|
CheerCount: number
|
||||||
|
Accessibility: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A player's public room — the narrow shape `/rooms/createdby/:id` serves. */
|
/** A player's public room — the narrow shape `/rooms/createdby/:id` serves. */
|
||||||
@@ -833,8 +838,15 @@ const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
|
|||||||
(matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null
|
(matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchPublicPhotos = (accountId: number): Promise<PublicPhoto[]> =>
|
/**
|
||||||
call<PublicPhoto[]>(`${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/<name>`, 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<PublicPhoto[]> =>
|
||||||
|
call<PublicPhoto[]>(`${where().api}/api/images/v4/player/${accountId}`, { authed: true })
|
||||||
|
|
||||||
const fetchPublicRooms = (accountId: number): Promise<PublicRoom[]> =>
|
const fetchPublicRooms = (accountId: number): Promise<PublicRoom[]> =>
|
||||||
call<PublicRoom[]>(`${where().rooms}/rooms/ownedby/${accountId}`)
|
call<PublicRoom[]>(`${where().rooms}/rooms/ownedby/${accountId}`)
|
||||||
@@ -849,19 +861,27 @@ async function fetchPublicRoom(roomId: number): Promise<OwnedRoom | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The public photos taken in a room. The feed serves the raw `SavedImage` record (`Id`,
|
* The public photos taken in a room. Asked for with `filter=1` (camera photos), as the
|
||||||
* not `SavedImageId` — see the api worker's note on why the two lists differ), so it's
|
* game asks: without it the feed also carries the room's own thumbnail. The feed serves
|
||||||
* narrowed here to the shape the photo grid draws.
|
* 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<PublicPhoto[]> =>
|
const fetchRoomPhotos = (roomId: number): Promise<PublicPhoto[]> =>
|
||||||
call<Array<{ Id: number; ImageName: string; CreatedAt: string; CheerCount: number }>>(
|
call<
|
||||||
`${where().api}/api/images/v4/room/${roomId}`
|
Array<{
|
||||||
).then((images) =>
|
Id: number
|
||||||
|
ImageName: string
|
||||||
|
CreatedAt: string
|
||||||
|
CheerCount: number
|
||||||
|
Accessibility: number
|
||||||
|
}>
|
||||||
|
>(`${where().api}/api/images/v4/room/${roomId}?filter=1`).then((images) =>
|
||||||
images.map((i) => ({
|
images.map((i) => ({
|
||||||
SavedImageId: i.Id,
|
SavedImageId: i.Id,
|
||||||
ImageName: i.ImageName,
|
ImageName: i.ImageName,
|
||||||
CreatedAt: i.CreatedAt,
|
CreatedAt: i.CreatedAt,
|
||||||
CheerCount: i.CheerCount,
|
CheerCount: i.CheerCount,
|
||||||
|
Accessibility: i.Accessibility,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -997,7 +1017,9 @@ function PhotoGrid({ photos }: { photos: PublicPhoto[] }) {
|
|||||||
key={p.SavedImageId}
|
key={p.SavedImageId}
|
||||||
className="photo-button"
|
className="photo-button"
|
||||||
onClick={() => setOpen(p)}
|
onClick={() => setOpen(p)}
|
||||||
aria-label="View photo"
|
aria-label={
|
||||||
|
p.Accessibility === Accessibility.Public ? 'View photo' : 'View private photo'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className="photo-thumb"
|
className="photo-thumb"
|
||||||
@@ -1005,6 +1027,12 @@ function PhotoGrid({ photos }: { photos: PublicPhoto[] }) {
|
|||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
{/* 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 && (
|
||||||
|
<span className="badge photo-private">Private</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1060,7 +1088,7 @@ function PlayerPage({
|
|||||||
void fetchPublicRooms(a.accountId)
|
void fetchPublicRooms(a.accountId)
|
||||||
.then(setRooms)
|
.then(setRooms)
|
||||||
.catch(() => setRooms([]))
|
.catch(() => setRooms([]))
|
||||||
void fetchPublicPhotos(a.accountId)
|
void fetchPlayerPhotos(a.accountId)
|
||||||
.then(setPhotos)
|
.then(setPhotos)
|
||||||
.catch(() => 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
|
* It reads the same list the profile does (`/api/images/v4/player/:id`); what makes it the
|
||||||
* (`/api/images/v4/player/:id`, which serves nothing private). Unlike "My rooms" there is
|
* owner's view is the session token going with the request, which `api` matches against
|
||||||
* no owner's view behind it yet: a photo kept private in game doesn't appear here.
|
* 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 }) {
|
function MyPhotos({ accountId }: { accountId: number }) {
|
||||||
const [photos, setPhotos] = useState<PublicPhoto[] | null>(null)
|
const [photos, setPhotos] = useState<PublicPhoto[] | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchPublicPhotos(accountId)
|
void fetchPlayerPhotos(accountId)
|
||||||
.then(setPhotos)
|
.then(setPhotos)
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
}, [accountId])
|
}, [accountId])
|
||||||
@@ -3197,15 +3227,16 @@ function MyPhotos({ accountId }: { accountId: number }) {
|
|||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>My photos</h2>
|
<h2>My photos</h2>
|
||||||
<p className="muted">The public photos you've taken in game, newest first.</p>
|
<p className="muted">
|
||||||
|
The photos you've taken in game, newest first. Ones you keep private are marked; only
|
||||||
|
you can see them here.
|
||||||
|
</p>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="error">{error}</p>
|
<p className="error">{error}</p>
|
||||||
) : photos === null ? (
|
) : photos === null ? (
|
||||||
<p className="muted">Loading…</p>
|
<p className="muted">Loading…</p>
|
||||||
) : photos.length === 0 ? (
|
) : photos.length === 0 ? (
|
||||||
<p className="muted">
|
<p className="muted">No photos yet. Photos you take in game show up here.</p>
|
||||||
No public photos yet. Photos you take in game and share publicly show up here.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<PhotoGrid photos={photos} />
|
<PhotoGrid photos={photos} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1863,6 +1863,7 @@ button[type='submit']:disabled {
|
|||||||
/* The thumbnail's click target: a real button, for the keyboard, with none of its looks. */
|
/* The thumbnail's click target: a real button, for the keyboard, with none of its looks. */
|
||||||
.photo-button {
|
.photo-button {
|
||||||
display: block;
|
display: block;
|
||||||
|
position: relative;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
@@ -1870,6 +1871,17 @@ button[type='submit']:disabled {
|
|||||||
cursor: zoom-in;
|
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 {
|
.photo-thumb {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -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
|
* for; an id with no record, or one that isn't public, is simply absent rather than a
|
||||||
* hole in the list.
|
* hole in the list.
|
||||||
*
|
*
|
||||||
* Public-only, like every other image read here ({@link getImagesByPlayer},
|
* Public-only, like the room feed ({@link getImagesByRoom}) and the player lists when
|
||||||
* {@link getImagesByRoom}). Image ids are sequential, so serving whatever an id names
|
* anyone but the owner reads them ({@link getImagesByPlayer}). Image ids are sequential,
|
||||||
* would make a private photo readable by anyone who counts.
|
* 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<SavedImage[]> {
|
export async function getImagesByIds(db: D1Database, ids: number[]): Promise<SavedImage[]> {
|
||||||
if (ids.length === 0) return []
|
if (ids.length === 0) return []
|
||||||
@@ -333,7 +334,28 @@ const newestFirst = (a: SavedImage, b: SavedImage) =>
|
|||||||
b.CreatedAt.localeCompare(a.CreatedAt) || b.Id - a.Id
|
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
|
* 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.
|
* 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,
|
playerId: number,
|
||||||
sort: number,
|
sort: number,
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number,
|
||||||
|
viewerId?: number | null
|
||||||
): Promise<SavedImage[]> {
|
): Promise<SavedImage[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare('SELECT data FROM image WHERE player_id = ?1')
|
.prepare('SELECT data FROM image WHERE player_id = ?1')
|
||||||
.bind(playerId)
|
.bind(playerId)
|
||||||
.all<ImageRow>()
|
.all<ImageRow>()
|
||||||
|
const own = ownView(playerId, viewerId)
|
||||||
return results
|
return results
|
||||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
.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)
|
.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||||
.slice(skip, skip + take)
|
.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
|
* A player's photo feed — the public camera photos ({@link isPhoto}) they took plus the
|
||||||
* tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a
|
* 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
|
* bare array of SavedImage. The tagged-in match uses json_each over the stored
|
||||||
* TaggedPlayerIds array (there's no index for it).
|
* 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(
|
export async function getPlayerFeed(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
playerId: number,
|
playerId: number,
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number,
|
||||||
|
viewerId?: number | null
|
||||||
): Promise<SavedImage[]> {
|
): Promise<SavedImage[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -554,9 +584,11 @@ export async function getPlayerFeed(
|
|||||||
)
|
)
|
||||||
.bind(playerId)
|
.bind(playerId)
|
||||||
.all<ImageRow>()
|
.all<ImageRow>()
|
||||||
|
const own = ownView(playerId, viewerId)
|
||||||
return results
|
return results
|
||||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
.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)
|
.sort(newestFirst)
|
||||||
.slice(skip, skip + take)
|
.slice(skip, skip + take)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user