import { Hono } from 'hono' import { authedId, unauthorized } from '../http' import { createImage, deleteImage, getCheeredImageIds, getImageByName, getImagesByPlayer, getImagesByRoom, getPlayerFeed, getSlideshowImages, SavedImageType, setImageCheer, toImagesPlayer, } from '../images-db' import type { App } from '../context' /** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */ const typeFolder: Record = { [SavedImageType.None]: 'none', [SavedImageType.ShareCamera]: 'sharecamera', [SavedImageType.OutfitThumbnail]: 'outfit', [SavedImageType.RoomThumbnail]: 'room', [SavedImageType.ProfileThumbnail]: 'profile', [SavedImageType.InventionThumbnail]: 'invention', } // ---- Images ---------------------------------------------------------------- export const imageRoutes = new Hono({ strict: false }) .get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json .post('/api/images/v4/uploadsaved', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const body = await c.req.parseBody().catch(() => ({}) as Record) // 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 = {} if (typeof body.imgMeta === 'string') { try { const parsed = JSON.parse(body.imgMeta) if (parsed && typeof parsed === 'object') meta = parsed as Record } 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', 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]+}', 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]+}', 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]+}', 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]+}', 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. .get('/api/images/v1/slideshow', async (c) => { const Images = await getSlideshowImages(c.env.DB) 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', 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', 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', 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) })) ) })