mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
Fix #13 add basic admin panel for now
This commit is contained in:
@@ -36,9 +36,25 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Saved-image categories from the C# `SavedImageType` enum — the value of a stored
|
||||
* image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives here in
|
||||
* the image data layer so both the upload route and the slideshow query share one
|
||||
* definition.
|
||||
*/
|
||||
export const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
/** A stored image record (the client-facing SavedImage shape). */
|
||||
export interface SavedImage {
|
||||
Id: number
|
||||
/** A {@link SavedImageType} value. */
|
||||
Type: number
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
@@ -275,11 +291,12 @@ async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number,
|
||||
}
|
||||
|
||||
/**
|
||||
* The global slideshow feed — the most recent publicly-listable images across all
|
||||
* rooms (Accessibility 0 or 1), newest first, capped at `limit`. Each row is joined
|
||||
* to its creator's username and (if any) its room's name. Returns the projected
|
||||
* SlideshowImage shape. Usernames/room names are resolved in two batched lookups to
|
||||
* avoid an N+1 across the (at most `limit`) images.
|
||||
* The global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`.
|
||||
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each
|
||||
* row is joined to its creator's username and (if any) its room's name. Returns the
|
||||
* projected SlideshowImage shape. Usernames/room names are resolved in two batched
|
||||
* lookups to avoid an N+1 across the (at most `limit`) images.
|
||||
*/
|
||||
export async function getSlideshowImages(
|
||||
db: D1Database,
|
||||
@@ -289,9 +306,10 @@ export async function getSlideshowImages(
|
||||
.prepare(
|
||||
`SELECT data FROM image
|
||||
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
||||
ORDER BY id DESC LIMIT ?1`
|
||||
AND json_extract(data, '$.Type') = ?1
|
||||
ORDER BY id DESC LIMIT ?2`
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(SavedImageType.ShareCamera, limit)
|
||||
.all<ImageRow>()
|
||||
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
|
||||
|
||||
@@ -8,22 +8,13 @@ import {
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
getSlideshowImages,
|
||||
SavedImageType,
|
||||
setImageCheer,
|
||||
} from '../images-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */
|
||||
const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
/** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */
|
||||
const typeFolder: Record<number, string> = {
|
||||
[SavedImageType.None]: 'none',
|
||||
@@ -150,13 +141,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
|
||||
})
|
||||
|
||||
// Global slideshow feed — the most recent publicly-listable images (Accessibility
|
||||
// 0 or 1) across all rooms, newest first, each joined to its creator's username
|
||||
// and room name. Auth-gated. Returns `{ Images, ValidTill }`, where ValidTill is a
|
||||
// short (2-minute) cache hint the client refreshes against.
|
||||
// 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 id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const Images = await getSlideshowImages(c.env.DB)
|
||||
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||
return c.json({ Images, ValidTill })
|
||||
|
||||
@@ -1065,6 +1065,7 @@ describe('images', () => {
|
||||
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
|
||||
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
|
||||
const fd = new FormData()
|
||||
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
|
||||
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||
@@ -1074,8 +1075,9 @@ describe('images', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
// Keyed by <type>/<date>/<uuid>.<ext> (the type folder mirrors the CDN layout).
|
||||
expect(ImageName).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
|
||||
/^sharecamera\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
|
||||
)
|
||||
|
||||
// The object is in the shared bucket under that key.
|
||||
@@ -1093,10 +1095,7 @@ describe('images', () => {
|
||||
expect(meta.CheerCount).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/images/v1/slideshow is auth-gated and joins username + room name', async () => {
|
||||
// No token → 401.
|
||||
expect((await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)).status).toBe(401)
|
||||
|
||||
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
|
||||
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
|
||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||
.bind(
|
||||
@@ -1118,9 +1117,8 @@ describe('images', () => {
|
||||
)
|
||||
.run()
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
// No token — the slideshow is public.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Images: Array<Record<string, unknown>>
|
||||
@@ -1287,8 +1285,9 @@ describe('images', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
// Type 4 → the `profile/` type folder, then <date>/<uuid>.<ext>.
|
||||
expect(ImageName).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
|
||||
/^profile\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
|
||||
)
|
||||
|
||||
// The account row now points its profileImage at the uploaded key.
|
||||
|
||||
Reference in New Issue
Block a user