mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[storage] upload hardening
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
MAX_BULK_OUTFIT_ACCOUNTS,
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
|
||||
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
@@ -473,13 +474,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Create a custom avatar item',
|
||||
description:
|
||||
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
|
||||
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
|
||||
'(PNG) and `design` (the design PNG). Inserts a `custom_avatar_item` row owned ' +
|
||||
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' +
|
||||
'which is what the store’s user-generated-content tab searches for.\n\n' +
|
||||
'The two files go to the shared image bucket (`recflare-img`) under ' +
|
||||
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
|
||||
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.\n\n' +
|
||||
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row. Each file is ' +
|
||||
'identified by its bytes — the client posts them under `.bin` names — and must be a ' +
|
||||
'PNG, JPEG, GIF, WebP or BMP; the key’s extension and the stored content-type come ' +
|
||||
'from that signature, and anything else is refused with a 400 before either file is ' +
|
||||
'written.\n\n' +
|
||||
'`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' +
|
||||
'0, is refused with a 400 rather than clamped.',
|
||||
security: AUTHED,
|
||||
@@ -488,7 +493,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
200: json(CustomAvatarItemResponse, 'The created item'),
|
||||
400: json(
|
||||
CustomAvatarItemResponse,
|
||||
'Missing or malformed metadata / files, or a `Price` outside 0–1000'
|
||||
'Missing or malformed metadata / files, a file that is not an image, or a `Price` outside 0–1000'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
|
||||
@@ -550,19 +555,30 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
)
|
||||
}
|
||||
|
||||
// The parts' filenames and content-types are the uploader's claims (the client
|
||||
// posts both as `.bin`); the `img` worker serves each back under whatever type is
|
||||
// stored, so the extension and the stored type come from the bytes. Both are
|
||||
// checked before either is written, so a refusal leaves nothing in R2.
|
||||
const thumbnailBytes = new Uint8Array(await body.thumbnailImage.arrayBuffer())
|
||||
const thumbnail = sniffImage(thumbnailBytes)
|
||||
if (!thumbnail) return fail(`thumbnailImage is not a ${SNIFFED_IMAGE_FORMATS} image`)
|
||||
const designBytes = new Uint8Array(await body.design.arrayBuffer())
|
||||
const design = sniffImage(designBytes)
|
||||
if (!design) return fail(`design is not a ${SNIFFED_IMAGE_FORMATS} image`)
|
||||
|
||||
// Both files go to the shared image bucket, foldered by upload date and keyed by
|
||||
// the item's id (chosen here so the keys can carry it). The `img` worker serves
|
||||
// them back by key.
|
||||
const customAvatarItemId = crypto.randomUUID()
|
||||
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
|
||||
const thumbnailImageFilename = `${prefix}-thumb.png`
|
||||
const designFilename = `${prefix}-design.png`
|
||||
const thumbnailImageFilename = `${prefix}-thumb${thumbnail.extension}`
|
||||
const designFilename = `${prefix}-design${design.extension}`
|
||||
await Promise.all([
|
||||
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
|
||||
c.env.IMAGES.put(thumbnailImageFilename, thumbnailBytes, {
|
||||
httpMetadata: { contentType: thumbnail.contentType },
|
||||
}),
|
||||
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.design.type || 'image/png' },
|
||||
c.env.IMAGES.put(designFilename, designBytes, {
|
||||
httpMetadata: { contentType: design.contentType },
|
||||
}),
|
||||
])
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SLIDESHOW_MAX_LIMIT,
|
||||
toImagesPlayer,
|
||||
} from '@repo/domain'
|
||||
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
@@ -217,12 +218,17 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' +
|
||||
'malformed JSON is tolerated and the image is still stored, just untyped. A ' +
|
||||
'`savedImageType` of 4 (profile thumbnail) additionally becomes the account’s ' +
|
||||
'avatar, persisted on the account row.',
|
||||
'avatar, persisted on the account row.\n\n' +
|
||||
'The file is identified by its bytes, not by the part’s filename or content-type: ' +
|
||||
'it must be a PNG, JPEG, GIF, WebP or BMP, and the stored key’s extension and ' +
|
||||
'content-type come from that signature. Anything else is refused with a 400 — the ' +
|
||||
'`img` worker serves the object back under its stored type, so an HTML body under ' +
|
||||
'an image name would otherwise be a page on the image origin.',
|
||||
security: AUTHED,
|
||||
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
|
||||
responses: {
|
||||
200: json(UploadImageResponse, 'The stored bucket key'),
|
||||
400: json(ErrorResponse, 'No file in the request'),
|
||||
400: json(ErrorResponse, 'No file in the request, or the file is not an image'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
|
||||
},
|
||||
@@ -262,10 +268,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const roomId = num(meta.roomId)
|
||||
const playerEventId = num(meta.playerEventId)
|
||||
|
||||
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 : '.jpg'
|
||||
// The part's filename and content-type are the uploader's claims; the `img` worker
|
||||
// serves the object back under whatever type is stored, so both the extension and
|
||||
// the stored type come from the bytes. A body that is not an image is refused.
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
const image = sniffImage(bytes)
|
||||
if (!image) return c.json({ error: `image is not a ${SNIFFED_IMAGE_FORMATS} image` }, 400)
|
||||
|
||||
// Store the upload in the shared image bucket under a random key, foldered by
|
||||
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
|
||||
@@ -273,10 +281,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
// key (slashes and all), which is the returned ImageName.
|
||||
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
|
||||
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
|
||||
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
|
||||
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type || 'image/jpeg' },
|
||||
})
|
||||
const name = typePrefix + datePrefix + crypto.randomUUID() + image.extension
|
||||
await c.env.IMAGES.put(name, bytes, { httpMetadata: { contentType: image.contentType } })
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user