From 0372b997fda0417f653075fd77483ee303e118fb Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 19 Jun 2026 00:55:31 -0400 Subject: [PATCH] pfps --- apps/accounts/src/accounts-db.ts | 24 +++++++++ apps/accounts/src/accounts.app.ts | 9 ++-- .../accounts/src/test/integration/api.test.ts | 28 +++++++++++ apps/api/src/api.app.ts | 41 ++++++++++++++- apps/api/src/test/integration/api.test.ts | 50 ++++++++++++++++++- 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/apps/accounts/src/accounts-db.ts b/apps/accounts/src/accounts-db.ts index 9cf71ec..1262ae6 100644 --- a/apps/accounts/src/accounts-db.ts +++ b/apps/accounts/src/accounts-db.ts @@ -98,6 +98,30 @@ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise +): Promise { + const current = (await getAccount(db, id)) ?? defaultAccount(id) + const updated: Account = { ...current, ...overrides, AccountId: id } + const data = JSON.stringify(updated) + const res = await db + .prepare('UPDATE accounts SET data = ?2 WHERE account_id = ?1') + .bind(id, data) + .run() + if (!res.meta.changes) { + await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(data).run() + } + return updated +} + /** * Create and persist a new account. The id is the next free integer (above the * seeded system accounts); the username is auto-assigned (players don't choose diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 3c884d8..9023354 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -3,7 +3,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' -import { createAccount, defaultAccount, getAccount, getAccountsByIds } from './accounts-db' +import { createAccount, defaultAccount, getAccount, getAccountsByIds, updateAccount } from './accounts-db' import { validateAndGetAccountId } from './jwt' import type { Context } from 'hono' @@ -77,7 +77,6 @@ const app = new Hono() // as null (the C# has no JsonIgnore on those, and they aren't enums). return c.json({ ...account, - ProfileImage: 'hdqeamlcmatc6qzoi2ybgf0ddijjcf.jpg', Email: null, Phone: null, Birthday: null, @@ -163,7 +162,11 @@ const app = new Hono() .put('/account/me/profileimage', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - await formField(c, 'imageName') // TODO: persist on the account row. + const imageName = await formField(c, 'imageName') + if (!imageName) return c.body(null, 400) + // Persist the new avatar key on the account row (the C# also fires an + // AccountUpdate websocket — no notify binding here, so it's omitted). + await updateAccount(c.env.DB, id, { ProfileImage: imageName }) return c.json({ success: true }) }) diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 9e62f69..6083434 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -170,4 +170,32 @@ describe('auth-gated endpoints', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({ success: true }) }) + + test('PUT /account/me/profileimage 401s without a token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/account/me/profileimage`, { + ...form({ imageName: 'abc.jpg' }), + }) + expect(res.status).toBe(401) + }) + + test('PUT /account/me/profileimage 400s without an imageName', async () => { + const res = await exports.default.fetch(`${ORIGIN}/account/me/profileimage`, { + ...form({}), + headers: { ...(await bearer('777')), 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(res.status).toBe(400) + }) + + test('PUT /account/me/profileimage persists the avatar on the account', async () => { + const res = await exports.default.fetch(`${ORIGIN}/account/me/profileimage`, { + ...form({ imageName: 'deadbeef.jpg' }), + headers: { ...(await bearer('777')), 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + + // The stored value is returned by the self account (no hardcoded override). + const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('777') }) + expect(((await me.json()) as { ProfileImage: string }).ProfileImage).toBe('deadbeef.jpg') + }) }) diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 36f9c8b..e34fe4c 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -25,6 +25,16 @@ import type { App } from './context' * Placeholder responses for file-backed endpoints are marked `TODO: hydrate`. */ +/** 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 + /** * Resolve the account id from a Bearer token, mirroring the repeated * auth-header check in the C#. Returns `null` when the header is missing, @@ -430,23 +440,50 @@ const app = new Hono({ strict: false }) // ---- Images --------------------------------------------------------------- .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) // 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; its `savedImageType` + // decides what (if anything) the image is recorded against. Mirrors the C# + // `SavedImageMetaDTO` / `SavedImageType` enum. + let savedImageType: number = SavedImageType.None + 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 + } catch { + // Malformed imgMeta — treat as an untyped upload (still stored). + } + } + 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 : '.png' + const extension = valid.includes(ext) ? ext : '.jpg' // Store the upload in the shared image bucket under a random key. The `img` // worker serves it back by that key, which is the returned ImageName. const name = crypto.randomUUID().replace(/-/g, '') + extension await c.env.IMAGES.put(name, await file.arrayBuffer(), { - httpMetadata: { contentType: file.type || 'image/png' }, + 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 accounts SET data = json_set(data, '$.ProfileImage', ?2) WHERE account_id = ?1" + ) + .bind(id, name) + .run() + } + return c.json({ ImageName: name }) }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 7a9060d..eba8e80 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -38,6 +38,20 @@ beforeAll(async () => { ).run() const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)') await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r)))) + + // Accounts table (matching the auth worker's migration) — uploadsaved records + // profile thumbnails on the account row. Seed the account the test token (sub + // 42) authenticates as. + await env.DB.prepare( + `CREATE TABLE IF NOT EXISTS accounts ( + data TEXT NOT NULL, + account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL, + username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL + )` + ).run() + await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') + .bind(JSON.stringify({ AccountId: 42, Username: 'Tester', ProfileImage: 'DefaultProfileImage.jpg' })) + .run() }) // Mint a token the way the `auth` worker does, using the same dev secret, so the @@ -346,6 +360,7 @@ describe('images', () => { const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, { method: 'POST', + headers: await bearer(), body: fd, }) expect(res.status).toBe(200) @@ -358,10 +373,43 @@ describe('images', () => { expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes) }) + test('POST /api/images/v4/uploadsaved records a profile thumbnail on the account', async () => { + const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]) + const fd = new FormData() + // Type 4 = ProfileThumbnail. The client sends the file as image.dat. + fd.append('imgMeta', JSON.stringify({ savedImageType: 4, roomId: -1 })) + fd.append('image', new File([bytes], 'image.dat', { type: 'image/jpeg' })) + + const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, { + method: 'POST', + headers: await bearer('42'), + body: fd, + }) + expect(res.status).toBe(200) + const { ImageName } = (await res.json()) as { ImageName: string } + expect(ImageName).toMatch(/^[0-9a-f]+\.jpg$/) + + // The account row now points its ProfileImage at the uploaded key. + const row = await env.DB.prepare('SELECT data FROM accounts WHERE account_id = 42').first<{ + data: string + }>() + expect(JSON.parse(row!.data).ProfileImage).toBe(ImageName) + }) + + test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => { + const fd = new FormData() + fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' })) + const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, { + method: 'POST', + body: fd, + }) + expect(res.status).toBe(401) + }) + test('POST /api/images/v4/uploadsaved 400s without a file', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'foo=bar', }) expect(res.status).toBe(400)