mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[studio] avatar authoring, managed prefab builds (#140)
This commit is contained in:
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
|
||||
|
||||
import { accountRoutes } from './routes/account'
|
||||
import { avatarRoutes } from './routes/avatar'
|
||||
import { avatarStudioRoutes } from './routes/avatar-studio'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { eventRoutes } from './routes/events'
|
||||
import { gameplayRoutes } from './routes/gameplay'
|
||||
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
|
||||
.route('/', configRoutes)
|
||||
.route('/', socialRoutes)
|
||||
.route('/', progressionRoutes)
|
||||
.route('/', avatarStudioRoutes)
|
||||
.route('/', avatarRoutes)
|
||||
.route('/', gameplayRoutes)
|
||||
.route('/', eventRoutes)
|
||||
|
||||
@@ -72,6 +72,9 @@ export interface CustomAvatarItemSave {
|
||||
UnityAssetHash: string
|
||||
UnityAsset2: string | null
|
||||
UnityAsset2Hash: string | null
|
||||
/** Studio builds persist separate target hashes; legacy imports omit these. */
|
||||
QuestUnityAssetHash?: string | null
|
||||
QuestUnityAsset2Hash?: string | null
|
||||
}
|
||||
|
||||
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
|
||||
@@ -356,6 +359,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
|
||||
...save,
|
||||
UnityAsset: questAsset(save.UnityAsset),
|
||||
UnityAsset2: questAsset(save.UnityAsset2),
|
||||
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
|
||||
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SLOTS,
|
||||
avatarItemAccessBit,
|
||||
avatarSaveDto,
|
||||
avatarStudioFlags,
|
||||
avatarStudioItem,
|
||||
avatarStudioSave,
|
||||
canManageRoomById,
|
||||
publishAvatarSave,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { parseMultipart } from '../../../studio/src/multipart'
|
||||
import { getCustomAvatarItem, toQuestCustomAvatarItem } from '../custom-avatar-items-db'
|
||||
import { authedId, authedRoles } from '../http'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { AvatarStudioSave } from '@repo/domain'
|
||||
import type { App } from '../context'
|
||||
|
||||
const uuid = '[0-9a-fA-F-]{36}'
|
||||
const ok = (value: unknown) => ({ success: true, error: null, value })
|
||||
const fail = (error: string) => ({ success: false, error, value: null })
|
||||
const slots = new Set(AVATAR_STUDIO_SLOTS)
|
||||
function validTags(value: unknown): boolean {
|
||||
return (
|
||||
value == null ||
|
||||
(Array.isArray(value) &&
|
||||
value.length <= 64 &&
|
||||
value.every(
|
||||
(t) =>
|
||||
t &&
|
||||
Number.isInteger(t.TagType) &&
|
||||
t.TagType >= 0 &&
|
||||
t.TagType <= 255 &&
|
||||
typeof t.Value === 'string' &&
|
||||
t.Value.length > 0 &&
|
||||
t.Value.length <= 64
|
||||
))
|
||||
)
|
||||
}
|
||||
async function allowed(c: Context<App>, accountId: number, slot: number) {
|
||||
const roles = await authedRoles(c)
|
||||
return (
|
||||
roles?.includes('betastudio') &&
|
||||
(![104, 203].includes(slot) || roles.includes('developer')) &&
|
||||
((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(slot)) !== 0
|
||||
)
|
||||
}
|
||||
const safeName = (value: unknown): value is string =>
|
||||
typeof value === 'string' &&
|
||||
/^[a-zA-Z0-9_./-]+$/.test(value) &&
|
||||
!value.split('/').some((p) => p === '..' || p === '.' || p === '')
|
||||
|
||||
export const avatarStudioRoutes = new Hono<App>()
|
||||
.get('/api/customAvatarItems/v1/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const rows = await c.env.DB.prepare(
|
||||
'SELECT data FROM custom_avatar_item WHERE creator_account_id=? ORDER BY created_at DESC,custom_avatar_item_id'
|
||||
)
|
||||
.bind(id)
|
||||
.all<{ data: string }>()
|
||||
const all = rows.results.map((r) => JSON.parse(r.data))
|
||||
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||
return c.json({ Results: all.slice(skip, skip + take), TotalResults: all.length })
|
||||
})
|
||||
.get(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c) => {
|
||||
const item = await getCustomAvatarItem(c.env.DB, c.req.param('id'))
|
||||
if (!item) return c.notFound()
|
||||
if (item.Accessibility === 0 && item.CreatorAccountId !== (await authedId(c)))
|
||||
return c.body(null, 403)
|
||||
return c.json(c.req.query('unityAssetTarget') === '2' ? toQuestCustomAvatarItem(item) : item)
|
||||
})
|
||||
.post('/api/customAvatarItems/v1', async (c, next) => {
|
||||
// Preserve the existing in-game custom-shirt contract, including its errors.
|
||||
const form = await parseMultipart(c.req.raw.clone())
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = JSON.parse(String(form.metadata))
|
||||
} catch {
|
||||
return next()
|
||||
}
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== 'object' ||
|
||||
body.BaseAvatarItemId !== undefined ||
|
||||
form.design ||
|
||||
form.thumbnailImage
|
||||
)
|
||||
return next()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const slot = Number(body.OutfitType)
|
||||
if (!Number.isInteger(slot) || !slots.has(slot))
|
||||
return c.json(fail('Unsupported avatar item type.'), 400)
|
||||
if (!(await allowed(c, id, slot)))
|
||||
return c.json(fail('Avatar Studio authorization is required.'), 403)
|
||||
const badgeRoomId = Number(body.BadgeRoomId ?? 0)
|
||||
if (
|
||||
slot === 1000 &&
|
||||
(!Number.isSafeInteger(badgeRoomId) ||
|
||||
badgeRoomId < 0 ||
|
||||
(badgeRoomId > 0 && !(await canManageRoomById(c.env.DB, badgeRoomId, id))))
|
||||
)
|
||||
return c.json(fail('Badge room is not managed by this account.'), 403)
|
||||
if (
|
||||
typeof body.Name !== 'string' ||
|
||||
!body.Name.trim() ||
|
||||
body.Name.length > 128 ||
|
||||
(body.Description != null &&
|
||||
(typeof body.Description !== 'string' || body.Description.length > 4000))
|
||||
)
|
||||
return c.json(fail('Invalid item name or description.'), 400)
|
||||
if (
|
||||
body.Price !== undefined &&
|
||||
body.Price !== null &&
|
||||
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)
|
||||
)
|
||||
return c.json(fail('Invalid item price.'), 400)
|
||||
if (body.Accessibility !== undefined && ![0, 1, 2].includes(Number(body.Accessibility)))
|
||||
return c.json(fail('Invalid accessibility.'), 400)
|
||||
if (body.Accessibility && body.Accessibility !== 0)
|
||||
return c.json(fail('Create the item privately and publish a built save first.'), 400)
|
||||
if (!validTags(body.Tags)) return c.json(fail('Invalid avatar tags.'), 400)
|
||||
const now = new Date().toISOString(),
|
||||
itemId = crypto.randomUUID()
|
||||
const item = {
|
||||
CustomAvatarItemId: itemId,
|
||||
CreatorAccountId: id,
|
||||
Name: body.Name.trim(),
|
||||
Description: body.Description ?? '',
|
||||
Price: body.Price ?? 0,
|
||||
Accessibility: body.Accessibility ?? 0,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: false,
|
||||
BaseAvatarItemId: null,
|
||||
BaseAvatarItemColor: null,
|
||||
DesignFilename: null,
|
||||
ThumbnailImageFilename: null,
|
||||
CreatedAt: now,
|
||||
ModifiedAt: now,
|
||||
PreviewOrientation: body.PreviewOrientation ?? 0,
|
||||
RankingContext: null,
|
||||
OutfitType: slot,
|
||||
CurrentSaves: [],
|
||||
Tags: body.Tags ?? [],
|
||||
IsStudioAuthored: true,
|
||||
CustomBadgeMetadata: slot === 1000 ? { RoomId: badgeRoomId } : null,
|
||||
RankedEntityId: itemId,
|
||||
PurchaseInfo: null,
|
||||
}
|
||||
await c.env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
|
||||
.bind(JSON.stringify(item))
|
||||
.run()
|
||||
return c.json(ok(item))
|
||||
})
|
||||
.put(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c, next) => {
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!existing?.IsStudioAuthored) return next()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
if (existing.CreatorAccountId !== id || !(await allowed(c, id, existing.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
|
||||
if (
|
||||
!body ||
|
||||
(body.Name != null &&
|
||||
(typeof body.Name !== 'string' || !body.Name.trim() || body.Name.length > 128)) ||
|
||||
(body.Description != null &&
|
||||
(typeof body.Description !== 'string' || body.Description.length > 4000)) ||
|
||||
(body.Price != null &&
|
||||
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)) ||
|
||||
(body.Accessibility != null && ![0, 1, 2].includes(Number(body.Accessibility))) ||
|
||||
!validTags(body.Tags)
|
||||
)
|
||||
return c.json(fail('Invalid avatar metadata.'), 400)
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (body.Accessibility && !item.CurrentSaves?.length)
|
||||
return c.json(fail('Publish a built save before making the item public.'), 400)
|
||||
const prior = JSON.stringify(item)
|
||||
for (const key of ['Name', 'Description', 'Price', 'Accessibility', 'Tags'])
|
||||
if (body[key] != null) item[key] = key === 'Name' ? String(body[key]).trim() : body[key]
|
||||
item.ModifiedAt = new Date().toISOString()
|
||||
const result = await c.env.DB.prepare(
|
||||
'UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)'
|
||||
)
|
||||
.bind(JSON.stringify(item), itemId, prior)
|
||||
.run()
|
||||
if (result.meta.changes) return c.json(ok(item))
|
||||
}
|
||||
return c.json(fail('The item changed concurrently. Retry the edit.'), 409)
|
||||
})
|
||||
.get(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const itemId = c.req.param('id'),
|
||||
item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id) return c.body(null, 403)
|
||||
const rows = await c.env.DB.prepare(
|
||||
'SELECT * FROM avatar_studio_save WHERE item_id=? ORDER BY id DESC'
|
||||
)
|
||||
.bind(itemId)
|
||||
.all<AvatarStudioSave>()
|
||||
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||
return c.json(
|
||||
ok({
|
||||
Results: await Promise.all(
|
||||
rows.results.slice(skip, skip + take).map((r) => avatarSaveDto(c.env.DB, r))
|
||||
),
|
||||
TotalResults: rows.results.length,
|
||||
})
|
||||
)
|
||||
})
|
||||
.post(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const itemId = c.req.param('id'),
|
||||
item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
|
||||
if (
|
||||
!body ||
|
||||
!Number.isInteger(body.BodyType) ||
|
||||
![0, 1, 2, 3, 4].includes(Number(body.BodyType)) ||
|
||||
!safeName(body.ThumbnailFileName) ||
|
||||
typeof body.AdditionalConfiguration !== 'string' ||
|
||||
body.AdditionalConfiguration.length > 1024 * 1024 ||
|
||||
(body.SaveDescription != null &&
|
||||
(typeof body.SaveDescription !== 'string' || body.SaveDescription.length > 4000))
|
||||
)
|
||||
return c.json(fail('Invalid avatar save.'), 400)
|
||||
try {
|
||||
JSON.parse(body.AdditionalConfiguration)
|
||||
} catch {
|
||||
return c.json(fail('Invalid additional configuration JSON.'), 400)
|
||||
}
|
||||
let filename: string, hash: string, unityId: string
|
||||
if (body.NewUnityAssetFile && typeof body.NewUnityAssetFile === 'object') {
|
||||
const file = body.NewUnityAssetFile as Record<string, unknown>
|
||||
if (!safeName(file.Filename)) return c.json(fail('Invalid source filename.'), 400)
|
||||
const object = await c.env.CDN_ASSETS.head('avatarunityasset/' + file.Filename)
|
||||
if (!object || object.customMetadata?.ownerId !== String(id))
|
||||
return c.json(fail('Source upload does not belong to this account.'), 403)
|
||||
const checksum = object.checksums.sha256
|
||||
hash = checksum ? btoa(String.fromCharCode(...new Uint8Array(checksum))) : ''
|
||||
if (!hash || file.Hash !== hash)
|
||||
return c.json(fail('Source hash does not match uploaded bytes.'), 400)
|
||||
filename = file.Filename
|
||||
unityId = crypto.randomUUID()
|
||||
} else if (typeof body.ExistingUnityAssetId === 'string') {
|
||||
const prior = await c.env.DB.prepare(
|
||||
'SELECT * FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.bind(itemId, body.ExistingUnityAssetId)
|
||||
.first<AvatarStudioSave>()
|
||||
if (!prior) return c.json(fail('No matching source revision on this item.'), 400)
|
||||
filename = prior.source_filename
|
||||
hash = prior.source_hash
|
||||
// A source can be reused across builds, but compiled outputs belong to
|
||||
// one body/configuration. Only metadata-only revisions share their output.
|
||||
unityId =
|
||||
prior.body_type === body.BodyType && prior.configuration === body.AdditionalConfiguration
|
||||
? prior.unity_asset_id
|
||||
: crypto.randomUUID()
|
||||
} else return c.json(fail('A new or existing source asset is required.'), 400)
|
||||
if (item.OutfitType === 1000) {
|
||||
const config = JSON.parse(body.AdditionalConfiguration as string)
|
||||
const badgeRoom = config?.d?.d?.r ?? 0
|
||||
if (
|
||||
config?.v !== 1 ||
|
||||
config?.d?.o !== 1000 ||
|
||||
!Number.isSafeInteger(badgeRoom) ||
|
||||
badgeRoom < 0
|
||||
)
|
||||
return c.json(fail('Invalid badge configuration.'), 400)
|
||||
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
|
||||
return c.json(fail('Badge room is not managed by this account.'), 403)
|
||||
if (body.BodyType !== 4)
|
||||
return c.json(fail('Badge images require the body-agnostic body type.'), 400)
|
||||
const source = await c.env.CDN_ASSETS.get('avatarunityasset/' + filename)
|
||||
if (!source || source.size > 8 * 1024 * 1024)
|
||||
return c.json(fail('Missing or oversized badge source.'), 400)
|
||||
const manifest = await source
|
||||
.json<{
|
||||
Version: number
|
||||
MainAssetProjectRelativePath: string
|
||||
Files: Array<{ p: string; n: string }>
|
||||
}>()
|
||||
.catch(() => null)
|
||||
if (
|
||||
!manifest ||
|
||||
manifest.Version !== 3 ||
|
||||
!Array.isArray(manifest.Files) ||
|
||||
!manifest.Files.length ||
|
||||
manifest.Files.length > 100000 ||
|
||||
!safeName(manifest.MainAssetProjectRelativePath) ||
|
||||
!manifest.MainAssetProjectRelativePath.startsWith('Assets/') ||
|
||||
!/\.(png|jpe?g)$/i.test(manifest.MainAssetProjectRelativePath) ||
|
||||
manifest.Files.some(
|
||||
(f) =>
|
||||
!f ||
|
||||
!safeName(f.p) ||
|
||||
!/^(Assets|Packages)\//.test(f.p) ||
|
||||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
|
||||
!/^[a-z0-9]{40}$/.test(f.n)
|
||||
) ||
|
||||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
|
||||
)
|
||||
return c.json(fail('Invalid badge image source manifest.'), 400)
|
||||
const main = manifest.Files.find((f) => f.p === manifest.MainAssetProjectRelativePath)
|
||||
const image = main ? await c.env.CDN_ASSETS.get('unityasset/' + main.n) : null
|
||||
if (!image || image.size > 16 * 1024 * 1024)
|
||||
return c.json(fail('Badge source image is missing or oversized.'), 400)
|
||||
const signature = new Uint8Array(await image.arrayBuffer()).subarray(0, 8)
|
||||
if (
|
||||
!(
|
||||
signature[0] === 137 &&
|
||||
signature[1] === 80 &&
|
||||
signature[2] === 78 &&
|
||||
signature[3] === 71
|
||||
) &&
|
||||
!(signature[0] === 255 && signature[1] === 216 && signature[2] === 255)
|
||||
)
|
||||
return c.json(fail('Badge source must be a PNG or JPEG image.'), 400)
|
||||
}
|
||||
for (const name of [body.ThumbnailFileName, body.ModerationImageFileName].filter(
|
||||
(v) => v !== undefined && v !== null
|
||||
)) {
|
||||
if (!safeName(name)) return c.json(fail('Invalid image filename.'), 400)
|
||||
const image = await c.env.CDN_ASSETS.get('image/' + name)
|
||||
if (!image || image.customMetadata?.ownerId !== String(id))
|
||||
return c.json(fail('Image upload does not belong to this account.'), 403)
|
||||
if (image.size > 16 * 1024 * 1024) return c.json(fail('Avatar image exceeds limit.'), 413)
|
||||
// Storage's image upload and the Images service use different buckets.
|
||||
// Keep the SDK filename unchanged when publishing creator-owned thumbnails.
|
||||
await c.env.IMAGES.put(name, image.body, { httpMetadata: image.httpMetadata })
|
||||
}
|
||||
const row = await c.env.DB.prepare(
|
||||
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,moderation_image,configuration,description,update_current,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING id'
|
||||
)
|
||||
.bind(
|
||||
itemId,
|
||||
body.BodyType,
|
||||
unityId,
|
||||
filename,
|
||||
hash,
|
||||
body.ThumbnailFileName,
|
||||
typeof body.ModerationImageFileName === 'string' ? body.ModerationImageFileName : null,
|
||||
body.AdditionalConfiguration,
|
||||
body.SaveDescription ?? null,
|
||||
body.UpdateCurrentSaveIfPrivate === true ? 1 : 0,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<{ id: number }>()
|
||||
if (body.UpdateCurrentSaveIfPrivate === true && item.Accessibility === 0)
|
||||
await publishAvatarSave(c.env.DB, itemId, row!.id, true)
|
||||
return c.json(ok(row!.id))
|
||||
})
|
||||
.post(`/api/customAvatarItems/:id{${uuid}}/saves/:saveId{[1-9][0-9]*}/publish`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const item = await avatarStudioItem(c.env.DB, c.req.param('id'))
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const save = await avatarStudioSave(
|
||||
c.env.DB,
|
||||
item.CustomAvatarItemId,
|
||||
Number(c.req.param('saveId'))
|
||||
)
|
||||
if (item.OutfitType === 1000 && save) {
|
||||
const badgeRoom = JSON.parse(save.configuration)?.d?.d?.r ?? 0
|
||||
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
|
||||
return c.body(null, 403)
|
||||
}
|
||||
if (!save || !(await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id)))
|
||||
return c.json(fail('Save has not completed a build.'), 400)
|
||||
return c.json(ok(null))
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SCHEMA,
|
||||
avatarSaveDto,
|
||||
avatarStudioSave,
|
||||
publishAvatarSave,
|
||||
ROOM_SCHEMA_DDL,
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
import { generateToken } from '@repo/jwt'
|
||||
|
||||
import { SCHEMA_DDL } from '../../custom-avatar-items-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
const base = 'https://example.com/api/customAvatarItems'
|
||||
async function headers(id = 42, roles = ['betastudio']) {
|
||||
return {
|
||||
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
|
||||
}
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
for (const sql of [
|
||||
...SCHEMA_DDL,
|
||||
...AVATAR_STUDIO_SCHEMA,
|
||||
...UNITY_ASSET_SCHEMA_DDL,
|
||||
...ROOM_SCHEMA_DDL,
|
||||
])
|
||||
await env.DB.prepare(sql).run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare('DELETE FROM avatar_studio_save').run()
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
await env.DB.prepare('UPDATE avatar_studio_access SET flags=1 WHERE account_id=42').run()
|
||||
})
|
||||
it('saves and publishes image badges directly, while enforcing badge grants and room ownership', async () => {
|
||||
expect((await create(42, ['betastudio'], 1000)).status).toBe(403)
|
||||
await env.DB.prepare('UPDATE avatar_studio_access SET flags=5 WHERE account_id=42').run()
|
||||
const foreign = new FormData()
|
||||
foreign.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Foreign badge', OutfitType: 1000, BadgeRoomId: 98765 })
|
||||
)
|
||||
expect(
|
||||
(await SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(), body: foreign }))
|
||||
.status
|
||||
).toBe(403)
|
||||
const { value: item } = await (
|
||||
await create(42, ['betastudio'], 1000)
|
||||
).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const png = Uint8Array.from(
|
||||
atob(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
|
||||
),
|
||||
(c) => c.charCodeAt(0)
|
||||
)
|
||||
const main = 'b'.repeat(40)
|
||||
const bytes = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
Version: 3,
|
||||
MainAssetProjectRelativePath: 'Assets/Badge.png',
|
||||
Files: [{ n: main, p: 'Assets/Badge.png' }],
|
||||
})
|
||||
)
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
await env.CDN_ASSETS.put('avatarunityasset/badge-source', bytes, {
|
||||
sha256: digest,
|
||||
customMetadata: { ownerId: '42' },
|
||||
})
|
||||
await env.CDN_ASSETS.put('unityasset/' + main, png)
|
||||
await env.CDN_ASSETS.put('image/badge.png', png, {
|
||||
customMetadata: { ownerId: '42' },
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
})
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const save = await SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
NewUnityAssetFile: {
|
||||
Filename: 'badge-source',
|
||||
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
|
||||
},
|
||||
BodyType: 4,
|
||||
ThumbnailFileName: 'badge.png',
|
||||
AdditionalConfiguration: JSON.stringify({
|
||||
v: 1,
|
||||
d: { n: 'Badge', o: 1000, d: { t: '000000' } },
|
||||
}),
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}),
|
||||
})
|
||||
expect(save.status).toBe(200)
|
||||
const { value: saveId } = await save.json<{ value: number }>()
|
||||
const detail = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
headers: await headers(),
|
||||
})
|
||||
expect(await detail.json()).toMatchObject({
|
||||
ThumbnailImageFilename: 'badge.png',
|
||||
CurrentSaves: [{ CustomAvatarItemSaveId: saveId, BodyType: 4, UnityAsset: null }],
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(url + '/' + saveId + '/publish', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
expect(await (await env.IMAGES.get('badge.png'))!.arrayBuffer()).toEqual(png.buffer)
|
||||
const published = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Accessibility: 1 }),
|
||||
})
|
||||
expect(published.status).toBe(200)
|
||||
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(200)
|
||||
})
|
||||
async function create(id = 42, roles = ['betastudio'], outfitType = 0) {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Studio Hat', OutfitType: outfitType, Accessibility: 0, Price: 0 })
|
||||
)
|
||||
return SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(id, roles), body: form })
|
||||
}
|
||||
it('accepts the SDK create request with nullable metadata fields', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
Name: 'SDK item',
|
||||
OutfitType: 101,
|
||||
BaseAvatarItemColor: '#101',
|
||||
BadgeRoomId: null,
|
||||
PreviewOrientation: null,
|
||||
Description: null,
|
||||
Tags: null,
|
||||
Price: null,
|
||||
Accessibility: null,
|
||||
})
|
||||
)
|
||||
const response = await SELF.fetch(base + '/v1', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
body: form,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
value: { Description: '', Tags: [], Price: 0, Accessibility: 0, CurrentSaves: [] },
|
||||
})
|
||||
})
|
||||
it('keeps grants per-account, rejects forbidden item categories, and creates metadata-only Studio items', async () => {
|
||||
expect((await create(43)).status).toBe(403)
|
||||
expect((await create(42, [])).status).toBe(403)
|
||||
expect((await create(42, ['betastudio'], 500)).status).toBe(403)
|
||||
const response = await create()
|
||||
expect(response.status).toBe(200)
|
||||
const { value: item } = await response.json<{
|
||||
value: { CustomAvatarItemId: string; OutfitType: number; CurrentSaves: unknown[] }
|
||||
}>()
|
||||
expect(item.OutfitType).toBe(0)
|
||||
expect(item.CurrentSaves).toEqual([])
|
||||
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(403)
|
||||
expect(
|
||||
(await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, { headers: await headers() })).status
|
||||
).toBe(200)
|
||||
const page = await SELF.fetch(base + '/v1/me', { headers: await headers() })
|
||||
expect(await page.json()).toMatchObject({
|
||||
TotalResults: 1,
|
||||
Results: [{ CustomAvatarItemId: item.CustomAvatarItemId }],
|
||||
})
|
||||
})
|
||||
it('protects source history, verifies upload ownership and hash, and never publishes unbuilt revisions', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const bytes = new TextEncoder().encode('{"Version":3}'),
|
||||
digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
await env.CDN_ASSETS.put('avatarunityasset/2026-10-18/source', bytes, {
|
||||
sha256: digest,
|
||||
customMetadata: { ownerId: '42' },
|
||||
})
|
||||
await env.CDN_ASSETS.put('image/thumb.png', 'thumbnail', {
|
||||
customMetadata: { ownerId: '42' },
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
})
|
||||
const body = {
|
||||
NewUnityAssetFile: {
|
||||
Filename: '2026-10-18/source',
|
||||
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
|
||||
},
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'thumb.png',
|
||||
AdditionalConfiguration: '{}',
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}
|
||||
const post = async (value: unknown, id = 42) =>
|
||||
SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value),
|
||||
})
|
||||
expect((await post(body, 43)).status).toBe(403)
|
||||
expect(
|
||||
(await post({ ...body, NewUnityAssetFile: { ...body.NewUnityAssetFile, Hash: 'wrong' } }))
|
||||
.status
|
||||
).toBe(400)
|
||||
const response = await post(body)
|
||||
expect(response.status).toBe(200)
|
||||
const { value: saveId } = await response.json<{ value: number }>()
|
||||
expect(await (await env.IMAGES.get('thumb.png'))?.text()).toBe('thumbnail')
|
||||
expect((await SELF.fetch(url, { headers: await headers(43) })).status).toBe(403)
|
||||
const history = await SELF.fetch(url, { headers: await headers() })
|
||||
expect(await history.json()).toMatchObject({
|
||||
success: true,
|
||||
value: {
|
||||
TotalResults: 1,
|
||||
Results: [
|
||||
{
|
||||
CustomAvatarItemSaveId: saveId,
|
||||
SourceUnityAssetFilename: body.NewUnityAssetFile.Filename,
|
||||
UnityAsset: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(url + '/' + saveId + '/publish', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
})
|
||||
).status
|
||||
).toBe(400)
|
||||
expect(
|
||||
(await post({ ...body, NewUnityAssetFile: null, ExistingUnityAssetId: crypto.randomUUID() }))
|
||||
.status
|
||||
).toBe(400)
|
||||
})
|
||||
it('allows creator metadata edits without exposing unbuilt items or changing ownership', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const edit = async (body: unknown, id = 42) =>
|
||||
SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
expect((await edit({ Name: 'Other owner' }, 43)).status).toBe(403)
|
||||
expect((await edit({ Accessibility: 1 })).status).toBe(400)
|
||||
expect((await edit({ Tags: [{ TagType: 1, Value: '' }] })).status).toBe(400)
|
||||
const response = await edit({
|
||||
Name: ' Updated hat ',
|
||||
Description: 'Edited in Studio',
|
||||
Tags: [{ TagType: 1, Value: 'hat' }],
|
||||
CreatorAccountId: 43,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
value: {
|
||||
Name: 'Updated hat',
|
||||
Description: 'Edited in Studio',
|
||||
CreatorAccountId: 42,
|
||||
Accessibility: 0,
|
||||
Tags: [{ TagType: 1, Value: 'hat' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates outputs across reused-source bodies/configurations and preserves original restore', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const asset = crypto.randomUUID()
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) VALUES (100,?,1,?,'same-source','same-hash','thumb.png','{}','now')"
|
||||
)
|
||||
.bind(item.CustomAvatarItemId, asset)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO unity_asset VALUES (?,0,5,'main','original.bundle','original-hash')"
|
||||
)
|
||||
.bind(asset)
|
||||
.run()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
|
||||
await env.CDN_ASSETS.put('image/thumb.png', 'fixture', { customMetadata: { ownerId: '42' } })
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const save = async (bodyType: number, config: string) => {
|
||||
const response = await SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ExistingUnityAssetId: asset,
|
||||
BodyType: bodyType,
|
||||
AdditionalConfiguration: config,
|
||||
ThumbnailFileName: 'thumb.png',
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const { value: id } = await response.json<{ value: number }>()
|
||||
return (await avatarStudioSave(env.DB, item.CustomAvatarItemId, id))!
|
||||
}
|
||||
const metadata = await save(1, '{}')
|
||||
expect(metadata.unity_asset_id).toBe(asset)
|
||||
const otherBody = await save(2, '{}'),
|
||||
otherConfig = await save(1, '{"variant":2}')
|
||||
for (const revision of [otherBody, otherConfig]) {
|
||||
expect(revision.unity_asset_id).not.toBe(asset)
|
||||
expect(revision.source_filename).toBe('same-source')
|
||||
expect(revision.source_hash).toBe('same-hash')
|
||||
expect((await avatarSaveDto(env.DB, revision)).UnityAsset).toBeNull()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, revision.id)).toBe(false)
|
||||
}
|
||||
expect(otherBody.unity_asset_id).not.toBe(otherConfig.unity_asset_id)
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO unity_asset VALUES (?,0,5,'main','other-body.bundle','other-hash')"
|
||||
)
|
||||
.bind(otherBody.unity_asset_id)
|
||||
.run()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, otherBody.id)).toBe(true)
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
|
||||
const stored = JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
|
||||
.bind(item.CustomAvatarItemId)
|
||||
.first<{ data: string }>())!.data
|
||||
)
|
||||
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 1).UnityAsset).toBe(
|
||||
'original.bundle'
|
||||
)
|
||||
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 2).UnityAsset).toBe(
|
||||
'other-body.bundle'
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user