mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-10 07:31:27 -07:00
docs for api
This commit is contained in:
+349
-150
@@ -1,4 +1,5 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
@@ -14,6 +15,28 @@ import {
|
||||
setImageCheer,
|
||||
toImagesPlayer,
|
||||
} from '../images-db'
|
||||
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'
|
||||
|
||||
@@ -29,198 +52,374 @@ const typeFolder: Record<number, string> = {
|
||||
|
||||
// ---- Images ----------------------------------------------------------------
|
||||
export const imageRoutes = new Hono<App>({ 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)
|
||||
.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 account’s ' +
|
||||
'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
|
||||
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` 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 })
|
||||
}
|
||||
// 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)
|
||||
.delete(
|
||||
'/api/images/v1/deletesaved',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Delete one of the caller’s 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 caller’s 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 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)
|
||||
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)
|
||||
// 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 })
|
||||
})
|
||||
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))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v4/room/:roomId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A room’s 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 room’s 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]+}', 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))
|
||||
})
|
||||
.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 ' +
|
||||
'`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 player’s 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]+}', 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))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v5/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s 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 player’s 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]+}', 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))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v3/feed/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
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.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s 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.
|
||||
.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 })
|
||||
})
|
||||
.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 creator’s 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.',
|
||||
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
|
||||
}),
|
||||
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()
|
||||
})
|
||||
.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', 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)
|
||||
.post(
|
||||
'/api/images/v1/cheer',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Cheer or un-cheer a photo',
|
||||
description:
|
||||
'Persists the caller’s cheer and resyncs the image’s `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 })
|
||||
}
|
||||
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) }))
|
||||
)
|
||||
})
|
||||
.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) }))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user