mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
true matchmaking
This commit is contained in:
+47
-6
@@ -9,6 +9,7 @@ import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json'
|
||||
import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json'
|
||||
import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json'
|
||||
import { defaultSettings } from './default-settings'
|
||||
import { createImage, getImageByName } from './images-db'
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db'
|
||||
|
||||
@@ -422,18 +423,24 @@ const app = new Hono<App>({ strict: false })
|
||||
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; its `savedImageType`
|
||||
// decides what (if anything) the image is recorded against. Mirrors the C#
|
||||
// `SavedImageMetaDTO` / `SavedImageType` enum.
|
||||
let savedImageType: number = SavedImageType.None
|
||||
// `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 meta = JSON.parse(body.imgMeta) as { savedImageType?: unknown } | null
|
||||
if (meta && typeof meta.savedImageType === 'number') savedImageType = meta.savedImageType
|
||||
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('.')
|
||||
@@ -457,9 +464,43 @@ const app = new Hono<App>({ strict: false })
|
||||
.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 })
|
||||
})
|
||||
|
||||
// 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. Stubbed
|
||||
// for now — accepted but not persisted; cheer storage is still TBD.
|
||||
.post('/api/images/v1/cheer', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: record the cheer against the image once cheer storage is designed.
|
||||
await c.req.json().catch(() => null)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// ---- Rooms ----------------------------------------------------------------
|
||||
// Room search filters. The client deserializes this into an object (not an
|
||||
// array) — shape from the 2025 reference.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
||||
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
||||
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
||||
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
||||
*
|
||||
* Mirror of `apps/img/src/images-db.ts` — the `img` worker owns the schema and
|
||||
* migration; this worker (which handles uploads + reads) keeps a copy in sync.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS image (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
|
||||
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
|
||||
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||
]
|
||||
|
||||
/** A stored image record (the client-facing SavedImage shape). */
|
||||
export interface SavedImage {
|
||||
Id: number
|
||||
Type: number
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
ImageName: string
|
||||
Description: string | null
|
||||
PlayerId: number
|
||||
TaggedPlayerIds: number[]
|
||||
RoomId: number | null
|
||||
PlayerEventId: number | null
|
||||
CreatedAt: string
|
||||
CheerCount: number
|
||||
CommentCount: number
|
||||
}
|
||||
|
||||
interface ImageRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
||||
export interface NewImage {
|
||||
imageName: string
|
||||
playerId: number
|
||||
type?: number
|
||||
accessibility?: number
|
||||
roomId?: number | null
|
||||
description?: string | null
|
||||
taggedPlayerIds?: number[]
|
||||
playerEventId?: number | null
|
||||
}
|
||||
|
||||
/** Insert a new image record for an upload, returning the stored row. */
|
||||
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
|
||||
.first<{ next: number }>()
|
||||
const image: SavedImage = {
|
||||
Id: row?.next ?? 1,
|
||||
Type: input.type ?? 1,
|
||||
Accessibility: input.accessibility ?? 1,
|
||||
AccessibilityLocked: false,
|
||||
ImageName: input.imageName,
|
||||
Description: input.description ?? null,
|
||||
PlayerId: input.playerId,
|
||||
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
||||
RoomId: input.roomId ?? null,
|
||||
PlayerEventId: input.playerEventId ?? null,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
}
|
||||
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
||||
return image
|
||||
}
|
||||
|
||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||
.bind(name)
|
||||
.first<ImageRow>()
|
||||
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../api.app'
|
||||
|
||||
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
@@ -58,6 +60,9 @@ beforeAll(async () => {
|
||||
JSON.stringify({ accountId: 42, username: 'Tester', profileImage: 'DefaultProfileImage.jpg' })
|
||||
)
|
||||
.run()
|
||||
|
||||
// Images table (owned by the img worker) — uploadsaved records a row here.
|
||||
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, using the same dev secret, so the
|
||||
@@ -369,6 +374,82 @@ describe('images', () => {
|
||||
const stored = await env.IMAGES.get(ImageName)
|
||||
expect(stored).not.toBeNull()
|
||||
expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes)
|
||||
|
||||
// A metadata row was created, and it's readable by name via /api/images/v6.
|
||||
const meta = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
||||
).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
|
||||
expect(meta.ImageName).toBe(ImageName)
|
||||
expect(meta.PlayerId).toBe(42)
|
||||
expect(typeof meta.Id).toBe('number')
|
||||
expect(meta.CheerCount).toBe(0)
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/cheer is auth-gated and stubs success', async () => {
|
||||
const body = JSON.stringify({ SavedImageId: 2, Cheer: true })
|
||||
// No token → 401.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
// With a token → accepted.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
|
||||
expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/api/images/v6?name=doesnotexist.jpg`)).status
|
||||
).toBe(404)
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved records metadata from imgMeta', async () => {
|
||||
const fd = new FormData()
|
||||
// The client's real imgMeta shape (tagged players are `playerIds`).
|
||||
fd.append(
|
||||
'imgMeta',
|
||||
JSON.stringify({
|
||||
playerIds: [5, 6],
|
||||
savedImageType: 1,
|
||||
roomId: 777,
|
||||
playerEventId: 0,
|
||||
accessibility: 2,
|
||||
})
|
||||
)
|
||||
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
body: fd,
|
||||
})
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
|
||||
const meta = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
||||
).json()) as {
|
||||
Type: number
|
||||
RoomId: number
|
||||
Accessibility: number
|
||||
TaggedPlayerIds: number[]
|
||||
PlayerEventId: number | null
|
||||
}
|
||||
expect(meta.Type).toBe(1)
|
||||
expect(meta.RoomId).toBe(777)
|
||||
expect(meta.Accessibility).toBe(2)
|
||||
expect(meta.TaggedPlayerIds).toEqual([5, 6])
|
||||
// playerEventId 0 means "none" → stored as null.
|
||||
expect(meta.PlayerEventId).toBeNull()
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved records a profile thumbnail on the account', async () => {
|
||||
|
||||
Reference in New Issue
Block a user