mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
pfps
This commit is contained in:
@@ -98,6 +98,30 @@ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<A
|
|||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge `overrides` into the account row for `id` and persist it. Reads the
|
||||||
|
* current account (falling back to a synthesized default), applies the
|
||||||
|
* overrides, and writes the whole JSON blob back — inserting the row when the
|
||||||
|
* account isn't in the table yet. Returns the updated account.
|
||||||
|
*/
|
||||||
|
export async function updateAccount(
|
||||||
|
db: D1Database,
|
||||||
|
id: number,
|
||||||
|
overrides: Partial<Account>
|
||||||
|
): Promise<Account> {
|
||||||
|
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
|
* 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
|
* seeded system accounts); the username is auto-assigned (players don't choose
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
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 { validateAndGetAccountId } from './jwt'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
@@ -77,7 +77,6 @@ const app = new Hono<App>()
|
|||||||
// as null (the C# has no JsonIgnore on those, and they aren't enums).
|
// as null (the C# has no JsonIgnore on those, and they aren't enums).
|
||||||
return c.json({
|
return c.json({
|
||||||
...account,
|
...account,
|
||||||
ProfileImage: 'hdqeamlcmatc6qzoi2ybgf0ddijjcf.jpg',
|
|
||||||
Email: null,
|
Email: null,
|
||||||
Phone: null,
|
Phone: null,
|
||||||
Birthday: null,
|
Birthday: null,
|
||||||
@@ -163,7 +162,11 @@ const app = new Hono<App>()
|
|||||||
.put('/account/me/profileimage', async (c) => {
|
.put('/account/me/profileimage', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(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 })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -170,4 +170,32 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ success: true })
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+39
-2
@@ -25,6 +25,16 @@ import type { App } from './context'
|
|||||||
* Placeholder responses for file-backed endpoints are marked `TODO: hydrate`.
|
* 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
|
* Resolve the account id from a Bearer token, mirroring the repeated
|
||||||
* auth-header check in the C#. Returns `null` when the header is missing,
|
* auth-header check in the C#. Returns `null` when the header is missing,
|
||||||
@@ -430,23 +440,50 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// ---- Images ---------------------------------------------------------------
|
// ---- Images ---------------------------------------------------------------
|
||||||
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
|
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
|
||||||
.post('/api/images/v4/uploadsaved', async (c) => {
|
.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>)
|
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||||
// The client posts the file as `image`; accept `file` too for safety.
|
// The client posts the file as `image`; accept `file` too for safety.
|
||||||
const candidate = body.image ?? body.file
|
const candidate = body.image ?? body.file
|
||||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||||
const file = candidate
|
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 valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
|
||||||
const dot = file.name.lastIndexOf('.')
|
const dot = file.name.lastIndexOf('.')
|
||||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
|
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`
|
// 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.
|
// worker serves it back by that key, which is the returned ImageName.
|
||||||
const name = crypto.randomUUID().replace(/-/g, '') + extension
|
const name = crypto.randomUUID().replace(/-/g, '') + extension
|
||||||
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
|
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 })
|
return c.json({ ImageName: name })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ beforeAll(async () => {
|
|||||||
).run()
|
).run()
|
||||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
|
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))))
|
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
|
// 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`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: await bearer(),
|
||||||
body: fd,
|
body: fd,
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -358,10 +373,43 @@ describe('images', () => {
|
|||||||
expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes)
|
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 () => {
|
test('POST /api/images/v4/uploadsaved 400s without a file', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: 'foo=bar',
|
body: 'foo=bar',
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
|
|||||||
Reference in New Issue
Block a user