Files
recflare/apps/api/src/routes/images.ts
T
2026-08-11 15:50:55 -04:00

444 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
createImage,
deleteImage,
getCheeredImageIds,
getImageByName,
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
getSlideshowImages,
SavedImageType,
setImageCheer,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImagesPlayer,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
AUTHED,
CheeredEntry,
CheerImageRequest,
DeleteImageRequest,
ErrorResponse,
form,
idParam,
ImagesPlayerDto,
intQuery,
json,
JsonArray,
jsonBody,
pageParams,
SavedImageDto,
SlideshowResponse,
stringQuery,
SuccessResponse,
UNAUTHORIZED_RESPONSE,
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import type { App } from '../context'
/** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */
const typeFolder: Record<number, string> = {
[SavedImageType.None]: 'none',
[SavedImageType.ShareCamera]: 'sharecamera',
[SavedImageType.OutfitThumbnail]: 'outfit',
[SavedImageType.RoomThumbnail]: 'room',
[SavedImageType.ProfileThumbnail]: 'profile',
[SavedImageType.InventionThumbnail]: 'invention',
}
// ---- Images ----------------------------------------------------------------
export const imageRoutes = new Hono<App>({ strict: false })
.get(
'/api/images/v2/named',
describeRoute({
tags: ['Images'],
summary: 'Named images',
description:
'The named-image catalog (UI art the client looks up by name). Not hydrated yet.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/namedimages.json
.post(
'/api/images/v4/uploadsaved',
describeRoute({
tags: ['Images'],
summary: 'Upload a saved image',
description:
'Stores a photo in the shared image bucket under a random key, foldered by image ' +
'type and upload date (e.g. `sharecamera/2026-06-15/…`) so the bucket stays ' +
'browsable. The returned `ImageName` is that key — the `img` worker serves the ' +
'object back by it, slashes and all.\n\n' +
'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' +
'malformed JSON is tolerated and the image is still stored, just untyped. A ' +
'`savedImageType` of 4 (profile thumbnail) additionally becomes the accounts ' +
'avatar, persisted on the account row.',
security: AUTHED,
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
responses: {
200: json(UploadImageResponse, 'The stored bucket key'),
400: json(ErrorResponse, 'No file in the request'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// The client posts the file as `image`; accept `file` too for safety.
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let meta: Record<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} catch {
// Malformed imgMeta — treat as an untyped upload (still stored).
}
}
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.')
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
const extension = valid.includes(ext) ? ext : '.jpg'
// Store the upload in the shared image bucket under a random key, foldered by
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
// the bucket stays browsable over time. The `img` worker serves it back by that
// key (slashes and all), which is the returned ImageName.
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'image/jpeg' },
})
// A profile thumbnail becomes the account's avatar — persist it on the
// account row (a JSON blob in the shared accounts table) so it sticks.
if (savedImageType === SavedImageType.ProfileThumbnail) {
await c.env.DB.prepare(
"UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
)
.bind(id, name)
.run()
}
// Record the image metadata (the `image` table the img worker owns), pulling
// the fields the client provided in imgMeta.
await createImage(c.env.DB, {
imageName: name,
playerId: id,
type: savedImageType,
accessibility: num(meta.accessibility),
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
description: typeof meta.description === 'string' ? meta.description : null,
taggedPlayerIds: Array.isArray(meta.playerIds)
? meta.playerIds.filter((v): v is number => typeof v === 'number')
: undefined,
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
})
return c.json({ ImageName: name })
}
)
// Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the
// image up by name, refuses unless the caller took it (PlayerId), then removes the
// metadata row (and its cheers) and the object from R2. 404 for an unknown image,
// 403 for someone else's.
.delete(
'/api/images/v1/deletesaved',
describeRoute({
tags: ['Images'],
summary: 'Delete one of the callers photos',
description:
'Looks the image up by name and refuses unless the caller took it, then removes ' +
'the metadata row (and its cheers) and the object from the bucket. The metadata ' +
'goes first; the R2 delete is idempotent, so a missing object is fine.',
security: AUTHED,
requestBody: jsonBody(DeleteImageRequest, 'The image to delete'),
responses: {
200: json(SuccessResponse, 'Deleted'),
400: json(ErrorResponse, 'No ImageName given'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers image'),
404: { description: 'No image by that name' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
// Drop the metadata (and cheers) first, then the object. An R2 delete is
// idempotent, so a missing object is fine.
await deleteImage(c.env.DB, image)
await c.env.IMAGES.delete(imageName)
return c.json({ success: true })
}
)
// A room's photo feed — the public images taken in that room. `sort` orders the
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
.get(
'/api/images/v4/room/:roomId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A rooms photo feed',
description:
'The public images taken in that room.\n\n' +
'This feed serves the RAW `SavedImage` record — unlike the player photo lists ' +
'below, which must serve the `ImagesPlayer` projection. The inconsistency is real ' +
'and load-bearing: both render correctly as they are, and unifying them breaks one ' +
'of them.',
parameters: [
idParam('roomId', 'Room id'),
intQuery('sort', '1 = most cheered; anything else = newest first'),
intQuery('filter', 'Narrow by SavedImageType; 0 = all'),
...pageParams(100),
],
responses: { 200: json(SavedImageDto.array(), 'The rooms photos') },
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const filter = Number.parseInt(c.req.query('filter') ?? '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
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
}
)
// 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).
.get(
'/api/images/v4/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players photos',
description:
'The public images that player has taken, newest first. Serves the clients ' +
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
'— the raw `SavedImage` renders blank thumbnails here.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The players photos') },
}),
async (c) => {
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)
return c.json(images.map(toImagesPlayer))
}
)
// A player's photos with a sort option. `sort` orders the list (1 = most
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
.get(
'/api/images/v5/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players photos, sortable',
description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.',
parameters: [
idParam('playerId', 'Account id'),
intQuery('sort', '1 = most cheered; anything else = newest first'),
...pageParams(100),
],
responses: { 200: json(ImagesPlayerDto.array(), 'The players photos') },
}),
async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
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)
return c.json(images.map(toImagesPlayer))
}
)
// A player's photo feed — the public images they took plus ones they're tagged
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array of
// the same ImagesPlayer projection the player photo lists use.
.get(
'/api/images/v3/feed/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players 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.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The players feed') },
}),
async (c) => {
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)
return c.json(images.map(toImagesPlayer))
}
)
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
// 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({
tags: ['Images'],
summary: 'The global slideshow feed',
description:
'The most recent publicly-listable ShareCamera photos across all rooms, newest ' +
'first, each joined to its creators username and room name.\n\n' +
'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) => {
// 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 })
}
)
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get(
'/api/images/v6',
describeRoute({
tags: ['Images'],
summary: 'Image metadata by filename',
description:
'The stored `SavedImage` record for a bucket key. 404s when the object exists but ' +
'has no metadata row.',
parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: {
200: json(SavedImageDto, 'The image record'),
400: json(ErrorResponse, 'No name given'),
404: { description: 'No metadata for that name' },
},
}),
async (c) => {
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(image) : c.notFound()
}
)
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Persists the
// caller's cheer to `image_interaction` and resyncs the image's CheerCount.
.post(
'/api/images/v1/cheer',
describeRoute({
tags: ['Images'],
summary: 'Cheer or un-cheer a photo',
description:
'Persists the callers cheer and resyncs the images `CheerCount`. A body naming no ' +
'`SavedImageId` is accepted and ignored — the ack is the same either way.',
security: AUTHED,
requestBody: jsonBody(CheerImageRequest, 'The image and the new cheer state'),
responses: {
200: json(SuccessResponse, 'Recorded'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
SavedImageId?: number
Cheer?: boolean
} | null
if (body && typeof body.SavedImageId === 'number') {
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
}
return c.json({ success: true })
}
)
// Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`,
// and each `id` may itself be a comma-separated list). Auth-gated. Returns one
// `{ SavedImageId, IsCheered }` per requested id, in order.
.get(
'/api/images/v5/cheered/bulk',
describeRoute({
tags: ['Images'],
summary: 'Which photos the caller has cheered',
description:
'One `{ SavedImageId, IsCheered }` per requested id, in request order — the client ' +
'fills in the cheer buttons on a photo grid from this.',
security: AUTHED,
parameters: [
intQuery('id', 'Repeatable; each value may be a comma-separated list of image ids'),
],
responses: {
200: json(CheeredEntry.array(), 'One entry per requested id, in order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ids =
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
return c.json(
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
)
}
)