mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
194 lines
8.7 KiB
TypeScript
194 lines
8.7 KiB
TypeScript
import { Hono } from 'hono'
|
|
|
|
import {
|
|
createImage,
|
|
getCheeredImageIds,
|
|
getImageByName,
|
|
getImagesByPlayer,
|
|
getImagesByRoom,
|
|
getPlayerFeed,
|
|
getSlideshowImages,
|
|
SavedImageType,
|
|
setImageCheer,
|
|
} from '../images-db'
|
|
import { authedId, unauthorized } from '../http'
|
|
|
|
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', (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<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 (the C# `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 })
|
|
})
|
|
|
|
// 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.
|
|
.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
|
|
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
|
|
})
|
|
|
|
// 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
|
|
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
|
|
})
|
|
|
|
// 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.
|
|
.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
|
|
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
|
|
})
|
|
|
|
// 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) })))
|
|
})
|