true matchmaking

This commit is contained in:
Devin Zuczek
2026-07-01 15:16:37 -04:00
parent 70aa33a27a
commit f48d78bd5f
15 changed files with 938 additions and 50 deletions
+47 -6
View File
@@ -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.
+91
View File
@@ -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
}
+81
View File
@@ -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 () => {