mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -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 { accountRoutes } from './routes/account'
|
||||||
import { avatarRoutes } from './routes/avatar'
|
import { avatarRoutes } from './routes/avatar'
|
||||||
|
import { avatarStudioRoutes } from './routes/avatar-studio'
|
||||||
import { configRoutes } from './routes/config'
|
import { configRoutes } from './routes/config'
|
||||||
import { eventRoutes } from './routes/events'
|
import { eventRoutes } from './routes/events'
|
||||||
import { gameplayRoutes } from './routes/gameplay'
|
import { gameplayRoutes } from './routes/gameplay'
|
||||||
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
.route('/', configRoutes)
|
.route('/', configRoutes)
|
||||||
.route('/', socialRoutes)
|
.route('/', socialRoutes)
|
||||||
.route('/', progressionRoutes)
|
.route('/', progressionRoutes)
|
||||||
|
.route('/', avatarStudioRoutes)
|
||||||
.route('/', avatarRoutes)
|
.route('/', avatarRoutes)
|
||||||
.route('/', gameplayRoutes)
|
.route('/', gameplayRoutes)
|
||||||
.route('/', eventRoutes)
|
.route('/', eventRoutes)
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export interface CustomAvatarItemSave {
|
|||||||
UnityAssetHash: string
|
UnityAssetHash: string
|
||||||
UnityAsset2: string | null
|
UnityAsset2: string | null
|
||||||
UnityAsset2Hash: 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). */
|
/** 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,
|
...save,
|
||||||
UnityAsset: questAsset(save.UnityAsset),
|
UnityAsset: questAsset(save.UnityAsset),
|
||||||
UnityAsset2: questAsset(save.UnityAsset2),
|
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'
|
||||||
|
)
|
||||||
|
})
|
||||||
+20
-6
@@ -362,6 +362,13 @@ const app = new Hono<App>()
|
|||||||
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SDK source downloads use an opaque upload capability (no bearer on this
|
||||||
|
// request). Only creator-authenticated save history exposes these filenames.
|
||||||
|
.get('/avatarunityasset/:filename{.+}', (c) => {
|
||||||
|
const name = c.req.param('filename')
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
|
||||||
|
return serveAsset(c, `avatarunityasset/${name}`)
|
||||||
|
})
|
||||||
// Editable Unity source manifests. Baked bundles are served separately.
|
// Editable Unity source manifests. Baked bundles are served separately.
|
||||||
.get(
|
.get(
|
||||||
'/roomunityasset/:filename{.+}',
|
'/roomunityasset/:filename{.+}',
|
||||||
@@ -430,15 +437,22 @@ const app = new Hono<App>()
|
|||||||
* a build is stored under it. Null when no build is, or the table has not been migrated
|
* a build is stored under it. Null when no build is, or the table has not been migrated
|
||||||
* yet (the rooms worker owns it).
|
* yet (the rooms worker owns it).
|
||||||
*/
|
*/
|
||||||
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
|
async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Inlined rather than imported so this worker does not take a dependency on the
|
// Inlined rather than imported so this worker does not take a dependency on the
|
||||||
// domain package.
|
// domain package.
|
||||||
const row = await db
|
const row = await env.DB.prepare(
|
||||||
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
|
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
|
||||||
|
)
|
||||||
.bind(filename)
|
.bind(filename)
|
||||||
.first<{ stored: number }>()
|
.first<{ stored: number }>()
|
||||||
return row ? filename : null
|
if (!row) return null
|
||||||
|
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
|
||||||
|
// Room builds and consumable exports use the game's /room namespace.
|
||||||
|
// Retain older installations that stored the same filename at the bucket root.
|
||||||
|
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
|
||||||
|
return `room/${filename}`
|
||||||
|
return filename
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
if (message.toLowerCase().includes('no such table')) return null
|
if (message.toLowerCase().includes('no such table')) return null
|
||||||
@@ -450,14 +464,14 @@ async function unityAssetR2Key(db: D1Database, filename: string): Promise<string
|
|||||||
async function serveUnityAsset(c: Context<App>) {
|
async function serveUnityAsset(c: Context<App>) {
|
||||||
const name = c.req.param('filename') ?? ''
|
const name = c.req.param('filename') ?? ''
|
||||||
if (name.includes('..')) return c.body(null, 400)
|
if (name.includes('..')) return c.body(null, 400)
|
||||||
const key = await unityAssetR2Key(c.env.DB, name)
|
const key = await unityAssetR2Key(c.env, name)
|
||||||
return serveAsset(c, key ?? `unityasset/${name}`)
|
return serveAsset(c, key ?? `unityasset/${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
||||||
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
||||||
if (name.includes('..')) return new Response(null, { status: 400 })
|
if (name.includes('..')) return new Response(null, { status: 400 })
|
||||||
const key = await unityAssetR2Key(env.DB, name)
|
const key = await unityAssetR2Key(env, name)
|
||||||
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
|
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
|
||||||
if (!head) return new Response(null, { status: 404 })
|
if (!head) return new Response(null, { status: 404 })
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
|
|||||||
|
|
||||||
const ORIGIN = 'https://example.com'
|
const ORIGIN = 'https://example.com'
|
||||||
|
|
||||||
|
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
|
||||||
|
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
|
||||||
|
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
|
||||||
|
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
|
||||||
|
'{"Version":3}'
|
||||||
|
)
|
||||||
|
await env.DB.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
|
||||||
|
).run()
|
||||||
|
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
|
||||||
|
)
|
||||||
|
.bind('avatar-fixture', 'material', name, 'hash')
|
||||||
|
.run()
|
||||||
|
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
|
||||||
|
expect(
|
||||||
|
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
|
||||||
|
).toBe(200)
|
||||||
|
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
|
||||||
|
headers: { Range: 'bytes=0-7' },
|
||||||
|
})
|
||||||
|
expect(response.status).toBe(206)
|
||||||
|
expect(await response.text()).toBe('UnityFS\0')
|
||||||
|
})
|
||||||
|
|
||||||
describe('cdn endpoints', () => {
|
describe('cdn endpoints', () => {
|
||||||
test('GET / reports service status', async () => {
|
test('GET / reports service status', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/`)
|
const res = await exports.default.fetch(`${ORIGIN}/`)
|
||||||
@@ -357,6 +383,18 @@ describe('cdn endpoints', () => {
|
|||||||
expect(head.status).toBe(200)
|
expect(head.status).toBe(200)
|
||||||
expect(head.headers.get('content-length')).toBe('4')
|
expect(head.headers.get('content-length')).toBe('4')
|
||||||
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
||||||
|
// Current room/consumable builds live in the game's room namespace.
|
||||||
|
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
|
||||||
|
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||||
|
headers: { Range: 'bytes=1-2' },
|
||||||
|
})
|
||||||
|
expect(current.status).toBe(206)
|
||||||
|
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
|
||||||
|
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||||
|
method: 'HEAD',
|
||||||
|
})
|
||||||
|
expect(currentHead.status).toBe(200)
|
||||||
|
expect(currentHead.headers.get('content-length')).toBe('5')
|
||||||
|
|
||||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
|
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -7185,3 +7185,27 @@ describe('Studio source revision permissions', () => {
|
|||||||
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
|
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('omits hydrated Badges when persisting a room settings edit', async () => {
|
||||||
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
|
RoomId: 987670,
|
||||||
|
Name: 'BadgeSerialization',
|
||||||
|
CreatorAccountId: 987670,
|
||||||
|
SubRooms: [],
|
||||||
|
})
|
||||||
|
await env.DB.prepare(
|
||||||
|
"UPDATE room SET data=json_set(data,'$.Badges',json(?)) WHERE room_id=987670"
|
||||||
|
)
|
||||||
|
.bind(JSON.stringify([{ Name: 'derived badge' }]))
|
||||||
|
.run()
|
||||||
|
const response = await SELF.fetch(`${ORIGIN}/rooms/987670/warning`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: await bearer('987670'),
|
||||||
|
body: new URLSearchParams({ warningMask: '1' }),
|
||||||
|
})
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const stored = await env.DB.prepare('SELECT data FROM room WHERE room_id=987670').first<{
|
||||||
|
data: string
|
||||||
|
}>()
|
||||||
|
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
|
||||||
|
})
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
|
|||||||
8: 'room',
|
8: 'room',
|
||||||
9: 'roomunityasset',
|
9: 'roomunityasset',
|
||||||
10: 'unityasset',
|
10: 'unityasset',
|
||||||
|
13: 'avatar',
|
||||||
|
14: 'avatarunityasset',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
|
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
|
||||||
@@ -380,6 +382,7 @@ const app = new Hono<App>()
|
|||||||
contentType = image.contentType
|
contentType = image.contentType
|
||||||
}
|
}
|
||||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
||||||
|
customMetadata: { ownerId: String(id) },
|
||||||
httpMetadata: { contentType },
|
httpMetadata: { contentType },
|
||||||
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
||||||
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
||||||
|
|||||||
@@ -21,6 +21,27 @@ beforeAll(async () => {
|
|||||||
// storage worker's validation accepts it.
|
// storage worker's validation accepts it.
|
||||||
const TEST_SECRET = 'test-signing-key'
|
const TEST_SECRET = 'test-signing-key'
|
||||||
|
|
||||||
|
it('stores avatar source and baked uploads with immutable account ownership metadata', async () => {
|
||||||
|
for (const [type, prefix] of [
|
||||||
|
[13, 'avatar'],
|
||||||
|
[14, 'avatarunityasset'],
|
||||||
|
] as const) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('fileType', String(type))
|
||||||
|
form.set('file', new File(['avatar source'], 'source.bin'))
|
||||||
|
const r = await SELF.fetch(ORIGIN + '/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer()), 'User-Agent': 'RRStudio' },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
const body = await r.json<{ Filename: string; Hash: string }>()
|
||||||
|
expect(body.Hash).toBeTruthy()
|
||||||
|
const object = await env.CDN_ASSETS.head(prefix + '/' + body.Filename)
|
||||||
|
expect(object?.customMetadata?.ownerId).toBe('42')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function b64url(input: ArrayBuffer | string): string {
|
function b64url(input: ArrayBuffer | string): string {
|
||||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||||
let binary = ''
|
let binary = ''
|
||||||
|
|||||||
+17
-2
@@ -96,5 +96,20 @@ allocated asset IDs and requested platforms are accepted. SDK bundle version 5
|
|||||||
is retained. Completion links the submitted save; auto-publish does not replace
|
is retained. Completion links the submitted save; auto-publish does not replace
|
||||||
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
|
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
|
||||||
Claims include a signed, short-lived SDK validation token with no account subject
|
Claims include a signed, short-lived SDK validation token with no account subject
|
||||||
or roles. Deploy the paired API support: only bulk component-text validation
|
or roles. Bulk component-text validation and job-scoped grant reads accept this
|
||||||
accepts this credential. It cannot authenticate as an account or publish a room.
|
credential. It cannot authenticate as an account or publish a room.
|
||||||
|
|
||||||
|
## Avatar Studio and consumable validation
|
||||||
|
|
||||||
|
Avatar authoring uses per-account Studio grants, source revisions, and the managed
|
||||||
|
cloud queue. Apply `migrations/0004_avatar_studio.sql` to the shared database before
|
||||||
|
enabling these routes. The API owns creator saves and explicit publication; the
|
||||||
|
Studio service queues prefab builds and returns per-platform main/stripped bundles
|
||||||
|
at version 5 and material bundles at version 6. Image badges publish directly.
|
||||||
|
Changed body/configuration inputs receive distinct output IDs, and automatic
|
||||||
|
publication cannot replace a newer revision. Deploy the paired cloud-building
|
||||||
|
worker for prefab support; restricted validation credentials grant no account role.
|
||||||
|
|
||||||
|
The SDK's access override reads the target account's entitlement. Build-validation
|
||||||
|
credentials can inspect only the current owners of their active job's room; peers
|
||||||
|
can inspect grants only while they currently manage the same room.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id);
|
||||||
|
CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL);
|
||||||
+332
-58
@@ -1,11 +1,18 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AVATAR_STUDIO_SLOTS,
|
||||||
|
avatarItemAccessBit,
|
||||||
|
avatarStudioFlags,
|
||||||
|
avatarStudioItem,
|
||||||
|
avatarStudioSave,
|
||||||
|
canManageRoom,
|
||||||
canManageRoomById,
|
canManageRoomById,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
getSubRoom,
|
getSubRoom,
|
||||||
getSubRoomSaveById,
|
getSubRoomSaveById,
|
||||||
publicStudioBundleFilename,
|
publicStudioBundleFilename,
|
||||||
|
publishAvatarSave,
|
||||||
studioBundleR2Key,
|
studioBundleR2Key,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +23,7 @@ import {
|
|||||||
|
|
||||||
import { parseMultipart } from './multipart'
|
import { parseMultipart } from './multipart'
|
||||||
|
|
||||||
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
|
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
|
||||||
@@ -23,6 +31,20 @@ export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_j
|
|||||||
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
|
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
|
||||||
)`
|
)`
|
||||||
interface Job {
|
interface Job {
|
||||||
|
kind?: 'avatar'
|
||||||
|
avatarItemId?: string
|
||||||
|
avatar?: {
|
||||||
|
itemId: string
|
||||||
|
outfitType: number
|
||||||
|
bodyType: number
|
||||||
|
configuration: string
|
||||||
|
name: string
|
||||||
|
createdAt: string
|
||||||
|
price: number
|
||||||
|
creatorAccountId: number
|
||||||
|
canCreateRoomieEquipment: boolean
|
||||||
|
permittedItemTypes: number[]
|
||||||
|
}
|
||||||
buildId: string
|
buildId: string
|
||||||
roomId: number
|
roomId: number
|
||||||
subRoomId: number
|
subRoomId: number
|
||||||
@@ -34,7 +56,8 @@ interface Job {
|
|||||||
targets: number[]
|
targets: number[]
|
||||||
manifest: {
|
manifest: {
|
||||||
Version: number
|
Version: number
|
||||||
MainSceneProjectRelativePath: string
|
MainSceneProjectRelativePath?: string
|
||||||
|
MainPrefabProjectRelativePath?: string
|
||||||
ExportPartitionVersion: number
|
ExportPartitionVersion: number
|
||||||
Files: Array<{ n: string; p: string; g?: string }>
|
Files: Array<{ n: string; p: string; g?: string }>
|
||||||
Exports?: Array<{ e: string; d?: string[] }>
|
Exports?: Array<{ e: string; d?: string[] }>
|
||||||
@@ -51,6 +74,7 @@ const ok = (value: unknown) => ({ success: true, error: null, value })
|
|||||||
const fail = (error: string) => ({ success: false, error, value: null })
|
const fail = (error: string) => ({ success: false, error, value: null })
|
||||||
const ID = /^[1-9]\d*$/
|
const ID = /^[1-9]\d*$/
|
||||||
const guid = () => crypto.randomUUID()
|
const guid = () => crypto.randomUUID()
|
||||||
|
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
|
||||||
interface Artifact {
|
interface Artifact {
|
||||||
field: string
|
field: string
|
||||||
unityAssetId: string
|
unityAssetId: string
|
||||||
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
|
|||||||
unity_asset_id: string
|
unity_asset_id: string
|
||||||
created_by_account_id: number
|
created_by_account_id: number
|
||||||
}
|
}
|
||||||
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
|
|
||||||
function wire(row: CloudBuildRecord) {
|
function wire(row: CloudBuildRecord) {
|
||||||
return {
|
return {
|
||||||
cloudBuildId: row.cloud_build_id,
|
cloudBuildId: row.cloud_build_id,
|
||||||
@@ -116,7 +139,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
|
|||||||
.bind(roomId, subRoomId, saveId)
|
.bind(roomId, subRoomId, saveId)
|
||||||
.first<CloudBuildRecord>()
|
.first<CloudBuildRecord>()
|
||||||
}
|
}
|
||||||
|
async function pendingAvatarBuild(db: D1Database, itemId: string, saveId: number) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing') LIMIT 1"
|
||||||
|
)
|
||||||
|
.bind(itemId, saveId)
|
||||||
|
.first<CloudBuildRecord>()
|
||||||
|
}
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
|
.get('/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}', async (c) => {
|
||||||
|
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
if (id === null) return c.body(null, 401)
|
||||||
|
const item = await avatarStudioItem(c.env.DB, c.req.param('itemId'))
|
||||||
|
if (!item || item.CreatorAccountId !== id) return c.body(null, 403)
|
||||||
|
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||||
|
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||||
|
const { results } = await c.env.DB.prepare(
|
||||||
|
'SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) WHERE a.item_id=? ORDER BY b.started_at DESC'
|
||||||
|
)
|
||||||
|
.bind(item.CustomAvatarItemId)
|
||||||
|
.all<CloudBuildRecord>()
|
||||||
|
return c.json({
|
||||||
|
results: results.slice(skip, skip + take).map(wire),
|
||||||
|
totalResults: results.length,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post(
|
||||||
|
'/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}/save/:saveId{[1-9][0-9]*}',
|
||||||
|
(c) => enqueueAvatar(c, c.req.param('itemId'), Number(c.req.param('saveId')))
|
||||||
|
)
|
||||||
|
.post('/cloud-builds/custom-avatar-item/create-for-new-build-version', async (c) => {
|
||||||
|
const form = await c.req.parseBody()
|
||||||
|
const itemId = String(form.customAvatarItemId ?? ''),
|
||||||
|
assetId = String(form.unityAssetId ?? '')
|
||||||
|
const row = await c.env.DB.prepare(
|
||||||
|
'SELECT id FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
|
||||||
|
)
|
||||||
|
.bind(itemId, assetId)
|
||||||
|
.first<{ id: number }>()
|
||||||
|
if (!row) return c.notFound()
|
||||||
|
return enqueueAvatar(c, itemId, row.id, form)
|
||||||
|
})
|
||||||
.post('/cloud-builds/create', async (c) => {
|
.post('/cloud-builds/create', async (c) => {
|
||||||
const secret = await c.env.JWT_SECRET.get()
|
const secret = await c.env.JWT_SECRET.get()
|
||||||
const accountId = await validateAndGetAccountId(c.req.raw, secret)
|
const accountId = await validateAndGetAccountId(c.req.raw, secret)
|
||||||
@@ -272,11 +336,16 @@ const app = new Hono<App>()
|
|||||||
const form = await c.req.parseBody()
|
const form = await c.req.parseBody()
|
||||||
const id = textField(form.cloudBuildId)
|
const id = textField(form.cloudBuildId)
|
||||||
const row = await c.env.DB.prepare(
|
const row = await c.env.DB.prepare(
|
||||||
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
|
'SELECT room_id,created_by_account_id FROM studio_cloud_build WHERE cloud_build_id = ?'
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.first<{ room_id: number }>()
|
.first<{ room_id: number; created_by_account_id: number }>()
|
||||||
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
|
if (
|
||||||
|
!row ||
|
||||||
|
!(row.room_id === 0
|
||||||
|
? row.created_by_account_id === accountId
|
||||||
|
: await canManageRoomById(c.env.DB, row.room_id, accountId))
|
||||||
|
)
|
||||||
return c.json(fail('No accessible build.'), 404)
|
return c.json(fail('No accessible build.'), 404)
|
||||||
await c.env.DB.batch([
|
await c.env.DB.batch([
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
@@ -325,17 +394,13 @@ const app = new Hono<App>()
|
|||||||
})
|
})
|
||||||
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
|
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
|
||||||
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
|
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
|
||||||
|
const leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString()
|
||||||
const result = await c.env.DB.prepare(
|
const result = await c.env.DB.prepare(
|
||||||
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
|
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
|
||||||
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
||||||
c.req.param('id'),
|
|
||||||
leaseToken,
|
|
||||||
new Date().toISOString()
|
|
||||||
)
|
|
||||||
.run()
|
.run()
|
||||||
return c.json({ active: result.meta.changes > 0 })
|
return c.json(result.meta.changes > 0 ? { active: true, leaseUntil } : { active: false })
|
||||||
})
|
})
|
||||||
.post('/internal/cloud-builds/:id/finish', async (c) => {
|
.post('/internal/cloud-builds/:id/finish', async (c) => {
|
||||||
const form = await parseMultipart(c.req.raw)
|
const form = await parseMultipart(c.req.raw)
|
||||||
@@ -359,6 +424,26 @@ const app = new Hono<App>()
|
|||||||
])
|
])
|
||||||
return c.json(ok(null))
|
return c.json(ok(null))
|
||||||
}
|
}
|
||||||
|
if (job.kind === 'avatar') {
|
||||||
|
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
|
||||||
|
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
!save ||
|
||||||
|
item.CreatorAccountId !== job.accountId ||
|
||||||
|
!((await avatarStudioFlags(c.env.DB, job.accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||||
|
) {
|
||||||
|
await c.env.DB.batch([
|
||||||
|
c.env.DB.prepare(
|
||||||
|
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='building'"
|
||||||
|
).bind(id, lease.lease_token),
|
||||||
|
c.env.DB.prepare(
|
||||||
|
"UPDATE studio_cloud_build SET completed_at=?,error='Avatar source or authorization was removed.' WHERE cloud_build_id=? AND completed_at=''"
|
||||||
|
).bind(new Date().toISOString(), id),
|
||||||
|
])
|
||||||
|
return c.json(fail('Avatar source or authorization was removed.'), 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
|
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
|
||||||
let entries: unknown
|
let entries: unknown
|
||||||
try {
|
try {
|
||||||
@@ -368,7 +453,10 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
if (!Array.isArray(entries) || !entries.every(isArtifact))
|
if (!Array.isArray(entries) || !entries.every(isArtifact))
|
||||||
return c.json(fail('Invalid build artifact metadata.'), 400)
|
return c.json(fail('Invalid build artifact metadata.'), 400)
|
||||||
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
|
if (
|
||||||
|
!entries.length ||
|
||||||
|
entries.length > assets.size * job.targets.length * (job.kind === 'avatar' ? 3 : 2)
|
||||||
|
)
|
||||||
return c.json(fail('Missing build artifacts.'), 400)
|
return c.json(fail('Missing build artifacts.'), 400)
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -377,8 +465,10 @@ const app = new Hono<App>()
|
|||||||
if (
|
if (
|
||||||
!assets.has(entry.unityAssetId) ||
|
!assets.has(entry.unityAssetId) ||
|
||||||
!job.targets.includes(entry.target) ||
|
!job.targets.includes(entry.target) ||
|
||||||
!['main', 'stripped'].includes(entry.kind) ||
|
!(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
|
||||||
entry.version !== 5 ||
|
entry.kind
|
||||||
|
) ||
|
||||||
|
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
|
||||||
!(file instanceof File) ||
|
!(file instanceof File) ||
|
||||||
file.size === 0 ||
|
file.size === 0 ||
|
||||||
seen.has(key)
|
seen.has(key)
|
||||||
@@ -390,6 +480,11 @@ const app = new Hono<App>()
|
|||||||
for (const target of job.targets)
|
for (const target of job.targets)
|
||||||
if (!seen.has(`${asset}:${target}:main`))
|
if (!seen.has(`${asset}:${target}:main`))
|
||||||
return c.json(fail('A required asset bundle is missing.'), 400)
|
return c.json(fail('A required asset bundle is missing.'), 400)
|
||||||
|
if (
|
||||||
|
job.kind === 'avatar' &&
|
||||||
|
job.targets.some((target) => !seen.has(`${job.unityAssetId}:${target}:material`))
|
||||||
|
)
|
||||||
|
return c.json(fail('An avatar material bundle is missing.'), 400)
|
||||||
const writes: D1PreparedStatement[] = []
|
const writes: D1PreparedStatement[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const file = form[entry.field] as File
|
const file = form[entry.field] as File
|
||||||
@@ -416,8 +511,11 @@ const app = new Hono<App>()
|
|||||||
entry.target === 2 ? 'android' : 'windows',
|
entry.target === 2 ? 'android' : 'windows',
|
||||||
entry.kind
|
entry.kind
|
||||||
)
|
)
|
||||||
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
|
const filename =
|
||||||
const key = studioBundleR2Key(filename)
|
job.kind === 'avatar'
|
||||||
|
? `${entry.target === 2 ? 'quest/' : ''}studio-avatar/${job.avatarItemId}/${entry.unityAssetId}/${lease.lease_token}.${entry.kind}.assetbundle`
|
||||||
|
: baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
|
||||||
|
const key = job.kind === 'avatar' ? `avatar/${filename}` : studioBundleR2Key(filename)
|
||||||
await c.env.CDN_ASSETS.put(key, bytes)
|
await c.env.CDN_ASSETS.put(key, bytes)
|
||||||
keys.push(key)
|
keys.push(key)
|
||||||
writes.push(
|
writes.push(
|
||||||
@@ -439,48 +537,52 @@ const app = new Hono<App>()
|
|||||||
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
|
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
|
||||||
keys.push(serverDataKey)
|
keys.push(serverDataKey)
|
||||||
// The exact submitted save is linked; a newer draft must never be replaced.
|
// The exact submitted save is linked; a newer draft must never be replaced.
|
||||||
writes.push(
|
if (job.kind !== 'avatar')
|
||||||
c.env.DB.prepare(
|
|
||||||
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
|
||||||
).bind(
|
|
||||||
job.unityAssetId,
|
|
||||||
JSON.stringify({ Exports: job.exports }),
|
|
||||||
job.saveId,
|
|
||||||
job.subRoomId,
|
|
||||||
id,
|
|
||||||
lease.lease_token
|
|
||||||
)
|
|
||||||
)
|
|
||||||
writes.push(
|
|
||||||
c.env.DB.prepare(
|
|
||||||
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
|
|
||||||
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
|
|
||||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
|
||||||
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
|
|
||||||
)
|
|
||||||
if (job.autoPublish) {
|
|
||||||
// Test and update the staging slot in the same statement. A draft saved
|
|
||||||
// during compilation must not be replaced by this build's older save.
|
|
||||||
writes.push(
|
writes.push(
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
`UPDATE subroom SET current_save_id=?,
|
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||||
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
|
|
||||||
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
|
|
||||||
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
|
|
||||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
|
||||||
).bind(
|
).bind(
|
||||||
job.saveId,
|
job.unityAssetId,
|
||||||
job.saveId,
|
JSON.stringify({ Exports: job.exports }),
|
||||||
job.roomId,
|
|
||||||
job.subRoomId,
|
|
||||||
job.saveId,
|
|
||||||
job.saveId,
|
job.saveId,
|
||||||
job.subRoomId,
|
job.subRoomId,
|
||||||
id,
|
id,
|
||||||
lease.lease_token
|
lease.lease_token
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if (job.kind !== 'avatar') {
|
||||||
|
writes.push(
|
||||||
|
c.env.DB.prepare(
|
||||||
|
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
|
||||||
|
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
|
||||||
|
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||||
|
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
|
||||||
|
)
|
||||||
|
if (job.autoPublish) {
|
||||||
|
// Test and update the staging slot in the same statement. A draft saved
|
||||||
|
// during compilation must not be replaced by this build's older save.
|
||||||
|
writes.push(
|
||||||
|
c.env.DB.prepare(
|
||||||
|
`UPDATE subroom SET current_save_id=?,
|
||||||
|
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
|
||||||
|
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
|
||||||
|
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
|
||||||
|
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||||
|
).bind(
|
||||||
|
job.saveId,
|
||||||
|
job.saveId,
|
||||||
|
job.roomId,
|
||||||
|
job.subRoomId,
|
||||||
|
job.saveId,
|
||||||
|
job.saveId,
|
||||||
|
job.subRoomId,
|
||||||
|
id,
|
||||||
|
lease.lease_token
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writes.push(
|
writes.push(
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||||
@@ -491,20 +593,42 @@ const app = new Hono<App>()
|
|||||||
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
|
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
|
||||||
).bind(id, lease.lease_token)
|
).bind(id, lease.lease_token)
|
||||||
)
|
)
|
||||||
// Recheck the lease and target inside the publication transaction. R2
|
// Recheck authority, lease and target inside the final publication transaction.
|
||||||
// uploads can outlast a lease, and a room can be deleted while compiling.
|
|
||||||
const publicationTime = new Date().toISOString()
|
const publicationTime = new Date().toISOString()
|
||||||
|
const roomRow =
|
||||||
|
job.kind !== 'avatar'
|
||||||
|
? await c.env.DB.prepare('SELECT data FROM room WHERE room_id=?')
|
||||||
|
.bind(job.roomId)
|
||||||
|
.first<{ data: string }>()
|
||||||
|
: null
|
||||||
|
const authorizedRoom =
|
||||||
|
roomRow && canManageRoom(JSON.parse(roomRow.data), job.accountId) ? roomRow.data : null
|
||||||
|
const targetExists =
|
||||||
|
job.kind === 'avatar'
|
||||||
|
? `SELECT 1 FROM avatar_studio_save s JOIN custom_avatar_item i ON i.custom_avatar_item_id=s.item_id
|
||||||
|
JOIN avatar_studio_access a ON a.account_id=json_extract(i.data,'$.CreatorAccountId')
|
||||||
|
WHERE s.id=? AND s.item_id=? AND s.unity_asset_id=? AND a.account_id=? AND (a.flags & ?)<>0`
|
||||||
|
: `SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
|
||||||
|
JOIN room ON room.room_id=r.room_id
|
||||||
|
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=? AND room.data=?`
|
||||||
|
const targetBindings =
|
||||||
|
job.kind === 'avatar'
|
||||||
|
? [
|
||||||
|
job.saveId,
|
||||||
|
job.avatarItemId!,
|
||||||
|
job.unityAssetId,
|
||||||
|
job.accountId,
|
||||||
|
avatarItemAccessBit(job.avatar!.outfitType),
|
||||||
|
]
|
||||||
|
: [job.saveId, job.subRoomId, job.roomId, authorizedRoom]
|
||||||
const results = await c.env.DB.batch([
|
const results = await c.env.DB.batch([
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
|
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
|
||||||
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
|
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
|
||||||
AND (lease_until<=? OR NOT EXISTS (
|
AND (lease_until<=? OR NOT EXISTS (${targetExists}))`
|
||||||
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
|
).bind(id, lease.lease_token, publicationTime, ...targetBindings),
|
||||||
JOIN room ON room.room_id=r.room_id
|
|
||||||
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
|
|
||||||
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
|
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
|
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target authorization/save removed.'
|
||||||
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
|
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
|
||||||
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
|
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
|
||||||
).bind(publicationTime, id, id),
|
).bind(publicationTime, id, id),
|
||||||
@@ -512,7 +636,7 @@ const app = new Hono<App>()
|
|||||||
])
|
])
|
||||||
if (!results[results.length - 1].meta.changes) {
|
if (!results[results.length - 1].meta.changes) {
|
||||||
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
||||||
return c.json(fail('Build lease expired or target save removed.'), 409)
|
return c.json(fail('Build lease expired or target authorization/save removed.'), 409)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
||||||
@@ -523,6 +647,156 @@ const app = new Hono<App>()
|
|||||||
.run()
|
.run()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
if (job.kind === 'avatar') {
|
||||||
|
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
|
||||||
|
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
save &&
|
||||||
|
(job.autoPublish || (item.Accessibility === 0 && save.update_current === 1))
|
||||||
|
) {
|
||||||
|
await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id, true)
|
||||||
|
}
|
||||||
|
return c.json(ok(null))
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(ok(null))
|
return c.json(ok(null))
|
||||||
})
|
})
|
||||||
export default app
|
export default app
|
||||||
|
|
||||||
|
async function enqueueAvatar(
|
||||||
|
c: Context<App>,
|
||||||
|
itemId: string,
|
||||||
|
saveId: number,
|
||||||
|
supplied?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
if (accountId === null) return c.body(null, 401)
|
||||||
|
const item = await avatarStudioItem(c.env.DB, itemId),
|
||||||
|
save = await avatarStudioSave(c.env.DB, itemId, saveId)
|
||||||
|
if (!item || !save) return c.notFound()
|
||||||
|
if (item.OutfitType === 1000)
|
||||||
|
return c.json(fail('Badge images publish directly and do not require a Unity build.'), 400)
|
||||||
|
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
if (
|
||||||
|
item.CreatorAccountId !== accountId ||
|
||||||
|
!roles?.includes('betastudio') ||
|
||||||
|
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||||
|
)
|
||||||
|
return c.body(null, 403)
|
||||||
|
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
|
||||||
|
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||||
|
if (pending) return c.json(ok(wire(pending)))
|
||||||
|
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
|
||||||
|
if (!object || object.size > 8 * 1024 * 1024)
|
||||||
|
return c.json(fail('Missing or oversized avatar source manifest.'), 400)
|
||||||
|
let manifest: Job['manifest']
|
||||||
|
try {
|
||||||
|
manifest = await object.json<Job['manifest']>()
|
||||||
|
} catch {
|
||||||
|
return c.json(fail('Invalid source manifest JSON.'), 400)
|
||||||
|
}
|
||||||
|
const main = manifest?.MainPrefabProjectRelativePath
|
||||||
|
if (
|
||||||
|
!manifest ||
|
||||||
|
manifest.Version !== 3 ||
|
||||||
|
typeof main !== 'string' ||
|
||||||
|
!main.startsWith('Assets/') ||
|
||||||
|
!main.endsWith('.prefab') ||
|
||||||
|
!Array.isArray(manifest.Files) ||
|
||||||
|
!manifest.Files.length ||
|
||||||
|
manifest.Files.length > 100000 ||
|
||||||
|
!manifest.Files.some((f) => f?.p === main) ||
|
||||||
|
manifest.Files.some(
|
||||||
|
(f) =>
|
||||||
|
!f ||
|
||||||
|
typeof f.p !== 'string' ||
|
||||||
|
!/^(Assets|Packages)\//.test(f.p) ||
|
||||||
|
f.p.includes('\\') ||
|
||||||
|
f.p.includes(':') ||
|
||||||
|
f.p
|
||||||
|
.split('/')
|
||||||
|
.some(
|
||||||
|
(p) => p === '..' || p === '.' || p === '' || p.toLowerCase().endsWith('.bundle')
|
||||||
|
) ||
|
||||||
|
/\.(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 avatar source file manifest.'), 400)
|
||||||
|
const form = supplied ?? (await c.req.parseBody())
|
||||||
|
if (form.pipelineVersion !== undefined && String(form.pipelineVersion) !== '2')
|
||||||
|
return c.json(fail('Unsupported avatar build pipeline.'), 400)
|
||||||
|
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
|
||||||
|
if (!targets.length || targets.some((t) => t !== 0 && t !== 2))
|
||||||
|
return c.json(fail('Invalid build targets.'), 503)
|
||||||
|
const access = await avatarStudioFlags(c.env.DB, accountId)
|
||||||
|
const permittedItemTypes = AVATAR_STUDIO_SLOTS.filter(
|
||||||
|
(slot) =>
|
||||||
|
(access & avatarItemAccessBit(slot)) !== 0 &&
|
||||||
|
(![104, 203].includes(slot) || roles.includes('developer'))
|
||||||
|
)
|
||||||
|
const job: Job = {
|
||||||
|
kind: 'avatar',
|
||||||
|
avatarItemId: itemId,
|
||||||
|
avatar: {
|
||||||
|
itemId,
|
||||||
|
outfitType: item.OutfitType,
|
||||||
|
bodyType: save.body_type,
|
||||||
|
configuration: save.configuration,
|
||||||
|
name: String(item.Name ?? ''),
|
||||||
|
createdAt: save.created_at,
|
||||||
|
price: Number(item.Price ?? 0),
|
||||||
|
creatorAccountId: accountId,
|
||||||
|
canCreateRoomieEquipment: roles.includes('developer'),
|
||||||
|
permittedItemTypes,
|
||||||
|
},
|
||||||
|
buildId: numericId(),
|
||||||
|
roomId: 0,
|
||||||
|
subRoomId: 0,
|
||||||
|
saveId,
|
||||||
|
accountId,
|
||||||
|
unityAssetId: save.unity_asset_id,
|
||||||
|
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
|
||||||
|
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
|
||||||
|
targets,
|
||||||
|
manifest,
|
||||||
|
room: null,
|
||||||
|
exports: [],
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
// As for rooms, reserve the build and its queue/link rows in one transaction.
|
||||||
|
const created = await c.env.DB.batch([
|
||||||
|
c.env.DB.prepare(
|
||||||
|
`INSERT INTO studio_cloud_build(cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
|
||||||
|
SELECT ?,0,0,?,?,?,?,'',NULL WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM studio_avatar_build a JOIN studio_cloud_build_job j USING(cloud_build_id)
|
||||||
|
WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing'))`
|
||||||
|
).bind(job.buildId, saveId, job.unityAssetId, accountId, now, itemId, saveId),
|
||||||
|
c.env.DB.prepare(
|
||||||
|
'INSERT INTO studio_avatar_build(cloud_build_id,item_id,save_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
|
||||||
|
).bind(job.buildId, itemId, saveId, job.buildId),
|
||||||
|
c.env.DB.prepare(
|
||||||
|
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
|
||||||
|
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
|
||||||
|
])
|
||||||
|
if (!created[0].meta.changes) {
|
||||||
|
const existing = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||||
|
return existing
|
||||||
|
? c.json(ok(wire(existing)))
|
||||||
|
: c.json(fail('Build state changed; retry creation.'), 409)
|
||||||
|
}
|
||||||
|
return c.json(
|
||||||
|
ok(
|
||||||
|
wire({
|
||||||
|
cloud_build_id: job.buildId,
|
||||||
|
started_at: now,
|
||||||
|
completed_at: null,
|
||||||
|
unity_asset_id: job.unityAssetId,
|
||||||
|
created_by_account_id: accountId,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,22 +109,29 @@ export async function getUserBuildCount(
|
|||||||
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT COUNT(*) AS total,
|
`SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
|
||||||
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed
|
SUM(CASE WHEN room_id!=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS failed,
|
||||||
|
SUM(CASE WHEN room_id=0 THEN 1 ELSE 0 END) AS avatar_total,
|
||||||
|
SUM(CASE WHEN room_id=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS avatar_failed
|
||||||
FROM studio_cloud_build
|
FROM studio_cloud_build
|
||||||
WHERE created_by_account_id = ?1
|
WHERE created_by_account_id = ?1
|
||||||
AND started_at >= ?2 AND started_at < ?3`
|
AND started_at >= ?2 AND started_at < ?3`
|
||||||
)
|
)
|
||||||
.bind(accountId, start.toISOString(), end.toISOString())
|
.bind(accountId, start.toISOString(), end.toISOString())
|
||||||
.first<{ total: number | string; failed: number | string | null }>()
|
.first<{
|
||||||
|
total: number | string
|
||||||
|
failed: number | string | null
|
||||||
|
avatar_total: number | null
|
||||||
|
avatar_failed: number | null
|
||||||
|
}>()
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
startTime: start.toISOString(),
|
startTime: start.toISOString(),
|
||||||
endTime: end.toISOString(),
|
endTime: end.toISOString(),
|
||||||
subRoomTotalBuildCount: Number(row?.total ?? 0),
|
subRoomTotalBuildCount: Number(row?.total ?? 0),
|
||||||
subRoomFailedBuildCount: Number(row?.failed ?? 0),
|
subRoomFailedBuildCount: Number(row?.failed ?? 0),
|
||||||
avatarItemTotalBuildCount: 0,
|
avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
|
||||||
avatarItemFailedBuildCount: 0,
|
avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
canManageRoomById,
|
canManageRoomById,
|
||||||
|
getAccount,
|
||||||
getPlayerIdsInRoomSubRoom,
|
getPlayerIdsInRoomSubRoom,
|
||||||
getRoomCoOwnerIds,
|
getRoomCoOwnerIds,
|
||||||
getSubRoom,
|
getSubRoom,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
import cloudBuilds from './cloud-builds'
|
import cloudBuilds from './cloud-builds'
|
||||||
import distribution from './distribution'
|
import distribution from './distribution'
|
||||||
@@ -30,6 +31,13 @@ function positiveId(raw: string | undefined): number | null {
|
|||||||
return Number.isSafeInteger(id) ? id : null
|
return Number.isSafeInteger(id) ? id : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SQLite datetime() is UTC, but its space-separated output is not a .NET JSON DateTime. */
|
||||||
|
function accessTimestamp(value: string | undefined, fallback: string): string {
|
||||||
|
const normalized = value?.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/, '$1T$2Z')
|
||||||
|
const time = normalized ? Date.parse(normalized) : NaN
|
||||||
|
return Number.isFinite(time) ? new Date(time).toISOString() : fallback
|
||||||
|
}
|
||||||
|
|
||||||
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
|
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
|
||||||
const UPLOAD_FIRST =
|
const UPLOAD_FIRST =
|
||||||
'Upload the room before building locally. The latest save is where this build is recorded.'
|
'Upload the room before building locally. The latest save is where this build is recorded.'
|
||||||
@@ -168,6 +176,69 @@ const app = new Hono<App>()
|
|||||||
.get('/', async (c) => {
|
.get('/', async (c) => {
|
||||||
return c.text('hello, world!')
|
return c.text('hello, world!')
|
||||||
})
|
})
|
||||||
|
.get('/access/override/:accountId{[1-9][0-9]*}', async (c) => {
|
||||||
|
const secret = await c.env.JWT_SECRET.get()
|
||||||
|
const caller = await validateAndGetAccountId(c.req.raw, secret)
|
||||||
|
const id = Number(c.req.param('accountId'))
|
||||||
|
if (!Number.isSafeInteger(id)) return c.body(null, 400)
|
||||||
|
let roles = await validateAndGetRoles(c.req.raw, secret)
|
||||||
|
if (caller === null) {
|
||||||
|
const buildId = await studioBuildValidationId(c.req.raw, secret)
|
||||||
|
if (!buildId) return c.body(null, 401)
|
||||||
|
const job = await c.env.DB.prepare(
|
||||||
|
"SELECT payload FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='building' AND lease_until>?"
|
||||||
|
)
|
||||||
|
.bind(buildId, new Date().toISOString())
|
||||||
|
.first<{ payload: string }>()
|
||||||
|
if (!job) return c.body(null, 403)
|
||||||
|
const payload = JSON.parse(job.payload) as {
|
||||||
|
roomId: number
|
||||||
|
room?: { Roles?: Array<{ AccountId: number; Role: number }> }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!payload.roomId ||
|
||||||
|
!payload.room?.Roles?.some((r) => r.AccountId === id && [30, 255].includes(r.Role))
|
||||||
|
)
|
||||||
|
return c.body(null, 403)
|
||||||
|
if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
|
||||||
|
return c.body(null, 403)
|
||||||
|
const account = await getAccount(c.env.DB, id)
|
||||||
|
roles = account?.hasStudio ? ['betastudio'] : []
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
caller !== null &&
|
||||||
|
caller !== id &&
|
||||||
|
!roles?.some((r) => r === 'developer' || r === 'moderator')
|
||||||
|
) {
|
||||||
|
// The SDK validates a registry against every owner of its room. Allow
|
||||||
|
// peers to inspect grants only when both currently manage the same room.
|
||||||
|
const shared = await c.env.DB.prepare(
|
||||||
|
`SELECT 1 FROM room r WHERE
|
||||||
|
(r.creator_account_id=?1 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?1 AND json_extract(role.value,'$.Role') IN (30,255))) AND
|
||||||
|
(r.creator_account_id=?2 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?2 AND json_extract(role.value,'$.Role') IN (30,255))) LIMIT 1`
|
||||||
|
)
|
||||||
|
.bind(caller, id)
|
||||||
|
.first()
|
||||||
|
if (!shared) return c.body(null, 403)
|
||||||
|
}
|
||||||
|
// Inspection authorization above belongs to the caller; this grant belongs to the target.
|
||||||
|
if (caller !== null && caller !== id)
|
||||||
|
roles = (await getAccount(c.env.DB, id))?.hasStudio ? ['betastudio'] : []
|
||||||
|
const grant = await c.env.DB.prepare(
|
||||||
|
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?'
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.first<{ flags: number; created_at: string; modified_at: string }>()
|
||||||
|
const flags = roles?.includes('betastudio') ? (grant?.flags ?? 0) : 0
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
return c.json({
|
||||||
|
AccountId: id,
|
||||||
|
StudioAccess: roles?.includes('betastudio') ? 1 : 2,
|
||||||
|
AvatarStudioAccess: flags,
|
||||||
|
CreatedAt: accessTimestamp(grant?.created_at, now),
|
||||||
|
ModifiedAt: accessTimestamp(grant?.modified_at, now),
|
||||||
|
})
|
||||||
|
})
|
||||||
// There is no circuit recommendation corpus on a fresh deployment. An empty
|
// There is no circuit recommendation corpus on a fresh deployment. An empty
|
||||||
// list is the SDK's supported no-recommendations state, not a failed lookup.
|
// list is the SDK's supported no-recommendations state, not a failed lookup.
|
||||||
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
|
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
|
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AVATAR_STUDIO_SCHEMA,
|
||||||
|
avatarSaveDto,
|
||||||
|
avatarStudioSave,
|
||||||
|
publishAvatarSave,
|
||||||
|
ROOM_SCHEMA_DDL,
|
||||||
|
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||||
|
UNITY_ASSET_SCHEMA_DDL,
|
||||||
|
} from '@repo/domain'
|
||||||
|
import { generateToken } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL, toQuestCustomAvatarItem } from '../../../../api/src/custom-avatar-items-db'
|
||||||
|
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||||
|
|
||||||
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
|
declare module 'cloudflare:test' {
|
||||||
|
interface ProvidedEnv extends Env {}
|
||||||
|
}
|
||||||
|
const origin = 'https://example.com',
|
||||||
|
itemId = '11111111-1111-1111-1111-111111111111',
|
||||||
|
assetId = '22222222-2222-2222-2222-222222222222'
|
||||||
|
const worker = { Authorization: 'Bearer private-avatar-worker-key' }
|
||||||
|
async function auth(id = 42, roles = ['betastudio']) {
|
||||||
|
return {
|
||||||
|
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manifest = {
|
||||||
|
Version: 3,
|
||||||
|
MainPrefabProjectRelativePath: 'Assets/Hat.prefab',
|
||||||
|
Files: [{ n: 'a'.repeat(40), p: 'Assets/Hat.prefab' }],
|
||||||
|
}
|
||||||
|
beforeAll(async () => {
|
||||||
|
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||||
|
env.CLOUD_BUILD_KEY = 'private-avatar-worker-key'
|
||||||
|
env.CLOUD_BUILD_TARGETS = '0,2'
|
||||||
|
for (const sql of [
|
||||||
|
...SCHEMA_DDL,
|
||||||
|
...ROOM_SCHEMA_DDL,
|
||||||
|
...AVATAR_STUDIO_SCHEMA,
|
||||||
|
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||||
|
...UNITY_ASSET_SCHEMA_DDL,
|
||||||
|
CLOUD_JOB_SCHEMA,
|
||||||
|
])
|
||||||
|
await env.DB.prepare(sql).run()
|
||||||
|
})
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const table of [
|
||||||
|
'studio_cloud_build_job',
|
||||||
|
'studio_cloud_build',
|
||||||
|
'studio_avatar_build',
|
||||||
|
'unity_asset',
|
||||||
|
'avatar_studio_save',
|
||||||
|
'avatar_studio_access',
|
||||||
|
'custom_avatar_item',
|
||||||
|
])
|
||||||
|
await env.DB.prepare('DELETE FROM ' + table).run()
|
||||||
|
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
|
||||||
|
await env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
CustomAvatarItemId: itemId,
|
||||||
|
CreatorAccountId: 42,
|
||||||
|
OutfitType: 0,
|
||||||
|
Accessibility: 0,
|
||||||
|
Name: 'Hat',
|
||||||
|
CurrentSaves: [],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
await env.DB.prepare(
|
||||||
|
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,update_current,created_at) VALUES (1,?,1,?,'source','hash','thumb','{}',1,'2026-10-18')"
|
||||||
|
)
|
||||||
|
.bind(itemId, assetId)
|
||||||
|
.run()
|
||||||
|
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
|
||||||
|
})
|
||||||
|
async function create(id = 42) {
|
||||||
|
return SELF.fetch(origin + '/cloud-builds/custom-avatar-item/' + itemId + '/save/1', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await auth(id),
|
||||||
|
body: new URLSearchParams({ pipelineVersion: '2', autoPublish: 'true' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function claim() {
|
||||||
|
const r = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: worker,
|
||||||
|
})
|
||||||
|
return r.json<{ job: { buildId: string; kind: string; manifest: unknown }; leaseToken: string }>()
|
||||||
|
}
|
||||||
|
function artifacts(lease: { leaseToken: string }, materialVersion = 6) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('leaseToken', lease.leaseToken)
|
||||||
|
const entries = []
|
||||||
|
for (const target of [0, 2])
|
||||||
|
for (const kind of ['main', 'material', 'stripped']) {
|
||||||
|
const field = `bundle${target}${kind}`
|
||||||
|
form.set(
|
||||||
|
field,
|
||||||
|
new File(['UnityFS\0avatar fixture ' + target + kind], field + '.assetbundle')
|
||||||
|
)
|
||||||
|
entries.push({
|
||||||
|
field,
|
||||||
|
unityAssetId: assetId,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
version: kind === 'material' ? materialVersion : 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
form.set('artifacts', JSON.stringify(entries))
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
it('authorizes per-account access and rejects builds from other owners', async () => {
|
||||||
|
expect((await SELF.fetch(origin + '/access/override/42')).status).toBe(401)
|
||||||
|
expect(
|
||||||
|
(await SELF.fetch(origin + '/access/override/42', { headers: await auth(43) })).status
|
||||||
|
).toBe(403)
|
||||||
|
expect(
|
||||||
|
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
|
||||||
|
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 1 })
|
||||||
|
expect(
|
||||||
|
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth(42, []) })).json()
|
||||||
|
).toMatchObject({ AvatarStudioAccess: 0 })
|
||||||
|
expect((await create(43)).status).toBe(403)
|
||||||
|
})
|
||||||
|
it('serializes SQLite and ISO access timestamps as SDK-compatible UTC DateTimes', async () => {
|
||||||
|
await env.DB.prepare(
|
||||||
|
'UPDATE avatar_studio_access SET flags=15,created_at=?,modified_at=? WHERE account_id=42'
|
||||||
|
)
|
||||||
|
.bind('2026-10-19T00:45:00.372Z', '2026-10-19 16:44:56')
|
||||||
|
.run()
|
||||||
|
expect(
|
||||||
|
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
|
||||||
|
).toMatchObject({
|
||||||
|
AvatarStudioAccess: 15,
|
||||||
|
CreatedAt: '2026-10-19T00:45:00.372Z',
|
||||||
|
ModifiedAt: '2026-10-19T16:44:56.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('builds and publishes prefab revisions with real SDK main/material version distinctions', async () => {
|
||||||
|
expect((await create()).status).toBe(200)
|
||||||
|
const lease = await claim()
|
||||||
|
expect(lease.job.kind).toBe('avatar')
|
||||||
|
expect(lease.job.manifest).toMatchObject(manifest)
|
||||||
|
const finish = () => origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish'
|
||||||
|
expect(
|
||||||
|
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease, 5) }))
|
||||||
|
.status
|
||||||
|
).toBe(400)
|
||||||
|
expect(
|
||||||
|
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
|
||||||
|
).toBe(200)
|
||||||
|
const row = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
|
||||||
|
const item = JSON.parse(row!.data)
|
||||||
|
expect(item.CurrentSaves).toHaveLength(1)
|
||||||
|
expect(item.CurrentSaves[0]).toMatchObject({
|
||||||
|
CustomAvatarItemSaveId: 1,
|
||||||
|
BodyType: 1,
|
||||||
|
OutfitType: 0,
|
||||||
|
})
|
||||||
|
expect(item.CurrentSaves[0].UnityAsset).toMatch(/^studio-avatar\//)
|
||||||
|
expect(item.CurrentSaves[0].UnityAsset2).toContain('.material.assetbundle')
|
||||||
|
expect(item.CurrentSaves[0]).not.toHaveProperty('SourceUnityAssetFilename')
|
||||||
|
const saved = await avatarStudioSave(env.DB, itemId, 1),
|
||||||
|
dto = await avatarSaveDto(env.DB, saved!)
|
||||||
|
expect(dto.BakedAssets).toHaveLength(4)
|
||||||
|
expect(dto.BakedAssets.map((a) => a.Version).sort()).toEqual([5, 5, 6, 6])
|
||||||
|
const quest = toQuestCustomAvatarItem(item).CurrentSaves[0]
|
||||||
|
expect(quest.UnityAssetHash).toBe(
|
||||||
|
dto.BakedAssets.find((a) => a.Target === 2 && a.Version === 5)?.Hash
|
||||||
|
)
|
||||||
|
expect(quest.UnityAssetHash).not.toBe(item.CurrentSaves[0].UnityAssetHash)
|
||||||
|
expect((await env.CDN_ASSETS.get('avatar/' + dto.UnityAsset))?.size).toBeGreaterThan(8)
|
||||||
|
expect(
|
||||||
|
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
|
||||||
|
).toBe(409)
|
||||||
|
const count = await SELF.fetch(origin + '/cloud-builds/get-build-count-for-user', {
|
||||||
|
headers: await auth(),
|
||||||
|
})
|
||||||
|
expect(await count.json()).toMatchObject({
|
||||||
|
value: { subRoomTotalBuildCount: 0, avatarItemTotalBuildCount: 1 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('rejects source code paths and cannot cancel another account’s build', async () => {
|
||||||
|
await env.CDN_ASSETS.put(
|
||||||
|
'avatarunityasset/source',
|
||||||
|
JSON.stringify({ ...manifest, Files: [null] })
|
||||||
|
)
|
||||||
|
expect((await create()).status).toBe(400)
|
||||||
|
await env.CDN_ASSETS.put(
|
||||||
|
'avatarunityasset/source',
|
||||||
|
JSON.stringify({
|
||||||
|
...manifest,
|
||||||
|
Files: [...manifest.Files, { n: 'a'.repeat(40), p: 'Assets/Code.cs' }],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect((await create()).status).toBe(400)
|
||||||
|
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
|
||||||
|
await create()
|
||||||
|
const lease = await claim()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(401)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: await auth(43),
|
||||||
|
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(404)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: await auth(),
|
||||||
|
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(200)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: worker,
|
||||||
|
body: artifacts(lease),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(409)
|
||||||
|
})
|
||||||
|
it('does not publish a deleted item or an older revision after a newer save', async () => {
|
||||||
|
await create()
|
||||||
|
const lease = await claim()
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1'
|
||||||
|
).run()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: worker,
|
||||||
|
body: artifacts(lease),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(200)
|
||||||
|
const item = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
|
||||||
|
expect(JSON.parse(item!.data).CurrentSaves).toEqual([])
|
||||||
|
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
|
||||||
|
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
|
||||||
|
await env.DB.prepare('DELETE FROM studio_avatar_build').run()
|
||||||
|
await create()
|
||||||
|
const deleted = await claim()
|
||||||
|
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/internal/cloud-builds/' + deleted.job.buildId + '/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: worker,
|
||||||
|
body: artifacts(deleted),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates concurrent avatar requests and allows a new build after failure', async () => {
|
||||||
|
const responses = await Promise.all([create(), create(), create()])
|
||||||
|
const ids = await Promise.all(
|
||||||
|
responses.map(async (response) => {
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(new Set(ids).size).toBe(1)
|
||||||
|
for (const table of ['studio_cloud_build', 'studio_avatar_build', 'studio_cloud_build_job'])
|
||||||
|
expect(await env.DB.prepare('SELECT COUNT(*) AS n FROM ' + table).first()).toEqual({ n: 1 })
|
||||||
|
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='failed'").run()
|
||||||
|
expect(
|
||||||
|
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
|
||||||
|
).not.toBe(ids[0])
|
||||||
|
})
|
||||||
|
it.each([false, true])(
|
||||||
|
'checks automatic avatar revision ordering in the write, including CAS retry=%s, preserving explicit restore',
|
||||||
|
async (retry) => {
|
||||||
|
await env.DB.prepare("INSERT INTO unity_asset VALUES (?,0,5,'main','old.bundle','hash')")
|
||||||
|
.bind(assetId)
|
||||||
|
.run()
|
||||||
|
let intercepted = false
|
||||||
|
const DB = new Proxy(env.DB, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key === 'prepare')
|
||||||
|
return (sql: string) => {
|
||||||
|
const wrap = (statement: D1PreparedStatement): D1PreparedStatement =>
|
||||||
|
new Proxy(statement, {
|
||||||
|
get(stmt, method) {
|
||||||
|
if (method === 'bind') return (...values: unknown[]) => wrap(stmt.bind(...values))
|
||||||
|
if (method === 'run' && sql.startsWith('UPDATE custom_avatar_item'))
|
||||||
|
return async () => {
|
||||||
|
if (!intercepted) {
|
||||||
|
intercepted = true
|
||||||
|
await target
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT 2,item_id,body_type,'new-identity',source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1"
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
if (retry)
|
||||||
|
await target
|
||||||
|
.prepare(
|
||||||
|
"UPDATE custom_avatar_item SET data=json_set(data,'$.Name','Concurrent metadata')"
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
return stmt.run()
|
||||||
|
}
|
||||||
|
const value = Reflect.get(stmt, method)
|
||||||
|
return typeof value === 'function' ? value.bind(stmt) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return wrap(target.prepare(sql))
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, key)
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(await publishAvatarSave(DB, itemId, 1, true)).toBe(false)
|
||||||
|
expect(intercepted).toBe(true)
|
||||||
|
let item = JSON.parse(
|
||||||
|
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
|
||||||
|
)
|
||||||
|
expect(item.CurrentSaves).toEqual([])
|
||||||
|
expect(await publishAvatarSave(env.DB, itemId, 1)).toBe(true)
|
||||||
|
item = JSON.parse(
|
||||||
|
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
|
||||||
|
)
|
||||||
|
expect(item.CurrentSaves[0].CustomAvatarItemSaveId).toBe(1)
|
||||||
|
if (retry) expect(item.Name).toBe('Concurrent metadata')
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UNITY_ASSET_SCHEMA_DDL,
|
UNITY_ASSET_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function builtArtifacts(lease: Awaited<ReturnType<typeof claim>>) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('leaseToken', lease.leaseToken)
|
||||||
|
form.set('bundle', new File(['UnityFS\0regression'], 'bundle.assetbundle'))
|
||||||
|
form.set(
|
||||||
|
'artifacts',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
field: 'bundle',
|
||||||
|
unityAssetId: lease.job.unityAssetId,
|
||||||
|
target: 0,
|
||||||
|
kind: 'main',
|
||||||
|
version: 5,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
function finishBuild(
|
||||||
|
lease: Awaited<ReturnType<typeof claim>>,
|
||||||
|
form = builtArtifacts(lease),
|
||||||
|
bindings = env
|
||||||
|
) {
|
||||||
|
return cloudBuilds.request(
|
||||||
|
origin + `/internal/cloud-builds/${lease.job.buildId}/finish`,
|
||||||
|
{ method: 'POST', headers: worker, body: form },
|
||||||
|
bindings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
it.each(['before finish', 'before transaction'])(
|
||||||
|
'rejects removed room co-owner authority %s without linking or publishing',
|
||||||
|
async (timing) => {
|
||||||
|
const prior = await env.DB.prepare('SELECT data FROM room WHERE room_id=3000').first<{
|
||||||
|
data: string
|
||||||
|
}>()
|
||||||
|
const originalSave = await env.DB.prepare(
|
||||||
|
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||||
|
).first<{ data: string }>()
|
||||||
|
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
|
||||||
|
.bind(JSON.stringify([{ AccountId: 43, Role: 30 }]))
|
||||||
|
.run()
|
||||||
|
await create(43, true, { autoPublish: 'true' })
|
||||||
|
const lease = await claim()
|
||||||
|
const revoke = () =>
|
||||||
|
env.DB.prepare(
|
||||||
|
"UPDATE room SET data=json_set(data,'$.Roles',json('[]')) WHERE room_id=3000"
|
||||||
|
).run()
|
||||||
|
if (timing === 'before finish') await revoke()
|
||||||
|
const DB = new Proxy(env.DB, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key === 'batch')
|
||||||
|
return async (statements: D1PreparedStatement[]) => {
|
||||||
|
await revoke()
|
||||||
|
return target.batch(statements)
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, key)
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||||
|
).first()
|
||||||
|
).toEqual(originalSave)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
|
||||||
|
.bind(lease.job.unityAssetId)
|
||||||
|
.first()
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
|
||||||
|
.bind(lease.job.buildId)
|
||||||
|
.first()
|
||||||
|
).toEqual({ status: 'failed' })
|
||||||
|
expect(
|
||||||
|
await env.CDN_ASSETS.get(
|
||||||
|
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
|
||||||
|
)
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
(await env.CDN_ASSETS.list({ prefix: `room/studio/3000/${lease.job.unityAssetId}` }))
|
||||||
|
.objects
|
||||||
|
).toEqual([])
|
||||||
|
} finally {
|
||||||
|
await env.DB.prepare('UPDATE room SET data=? WHERE room_id=3000').bind(prior!.data).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
it.each(['expired lease', 'deleted save'])(
|
||||||
|
'cleans uploaded artifacts when final publication finds %s',
|
||||||
|
async (condition) => {
|
||||||
|
await create()
|
||||||
|
const lease = await claim()
|
||||||
|
const save = await env.DB.prepare(
|
||||||
|
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||||
|
).first<{ data: string }>()
|
||||||
|
const DB = new Proxy(env.DB, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key === 'batch')
|
||||||
|
return async (statements: D1PreparedStatement[]) => {
|
||||||
|
if (condition === 'expired lease')
|
||||||
|
await target
|
||||||
|
.prepare("UPDATE studio_cloud_build_job SET lease_until='2000-01-01'")
|
||||||
|
.run()
|
||||||
|
else
|
||||||
|
await target
|
||||||
|
.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002')
|
||||||
|
.run()
|
||||||
|
return target.batch(statements)
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, key)
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
|
||||||
|
.bind(lease.job.unityAssetId)
|
||||||
|
.first()
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
await env.CDN_ASSETS.get(
|
||||||
|
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
|
||||||
|
)
|
||||||
|
).toBeNull()
|
||||||
|
} finally {
|
||||||
|
if (condition === 'deleted save')
|
||||||
|
await env.DB.prepare('INSERT INTO subroom_save VALUES (3002,3001,?)').bind(save!.data).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
|
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as ACCOUNT_SCHEMA, AVATAR_STUDIO_SCHEMA, ROOM_SCHEMA_DDL } from '@repo/domain'
|
||||||
|
import { generateStudioBuildValidationToken, generateToken } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||||
|
|
||||||
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
|
declare module 'cloudflare:test' {
|
||||||
|
interface ProvidedEnv extends Env {}
|
||||||
|
}
|
||||||
|
const base = 'https://example.com/access/override/'
|
||||||
|
beforeAll(async () => {
|
||||||
|
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||||
|
for (const sql of [
|
||||||
|
...ACCOUNT_SCHEMA,
|
||||||
|
...ROOM_SCHEMA_DDL,
|
||||||
|
...AVATAR_STUDIO_SCHEMA,
|
||||||
|
CLOUD_JOB_SCHEMA,
|
||||||
|
])
|
||||||
|
await env.DB.prepare(sql).run()
|
||||||
|
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
|
||||||
|
.bind(JSON.stringify({ accountId: 42, username: 'Fixture', hasStudio: true }))
|
||||||
|
.run()
|
||||||
|
await env.DB.prepare('INSERT INTO room(data) VALUES (?)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({ RoomId: 3000, CreatorAccountId: 42, Roles: [{ AccountId: 42, Role: 255 }] })
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,8,'now','now')").run()
|
||||||
|
})
|
||||||
|
beforeEach(async () => {
|
||||||
|
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,status,lease_until,created_at) VALUES (?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
'123',
|
||||||
|
JSON.stringify({ roomId: 3000, room: { Roles: [{ AccountId: 42, Role: 255 }] } }),
|
||||||
|
'building',
|
||||||
|
new Date(Date.now() + 60000).toISOString(),
|
||||||
|
'now'
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
async function request(id: number, build = '123') {
|
||||||
|
return SELF.fetch(base + id, {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + (await generateStudioBuildValidationToken(build, 'test-key')),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
it('allows read-only consumable validation only for an active job’s room owners', async () => {
|
||||||
|
expect(await (await request(42)).json()).toMatchObject({
|
||||||
|
AccountId: 42,
|
||||||
|
StudioAccess: 1,
|
||||||
|
AvatarStudioAccess: 8,
|
||||||
|
})
|
||||||
|
expect((await request(43)).status).toBe(403)
|
||||||
|
expect((await request(42, '124')).status).toBe(403)
|
||||||
|
expect((await SELF.fetch(base + '42')).status).toBe(401)
|
||||||
|
})
|
||||||
|
it('rejects cancelled and expired jobs', async () => {
|
||||||
|
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='cancelled'").run()
|
||||||
|
expect((await request(42)).status).toBe(403)
|
||||||
|
await env.DB.prepare(
|
||||||
|
"UPDATE studio_cloud_build_job SET status='building',lease_until='2000-01-01'"
|
||||||
|
).run()
|
||||||
|
expect((await request(42)).status).toBe(403)
|
||||||
|
})
|
||||||
|
it('lets co-owners validate each other’s grants without exposing unrelated accounts', async () => {
|
||||||
|
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
|
||||||
|
.bind(
|
||||||
|
JSON.stringify([
|
||||||
|
{ AccountId: 42, Role: 255 },
|
||||||
|
{ AccountId: 43, Role: 30 },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
const headers = {
|
||||||
|
Authorization:
|
||||||
|
'Bearer ' + (await generateToken('43', 'fixture', 0, 'test-key', ['betastudio'])),
|
||||||
|
}
|
||||||
|
expect(await (await SELF.fetch(base + '42', { headers })).json()).toMatchObject({
|
||||||
|
AvatarStudioAccess: 8,
|
||||||
|
})
|
||||||
|
expect((await SELF.fetch(base + '44', { headers })).status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses target entitlement for staff inspections in both directions', async () => {
|
||||||
|
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
|
||||||
|
.bind(JSON.stringify({ accountId: 44, username: 'Disabled', hasStudio: false }))
|
||||||
|
.run()
|
||||||
|
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (44,15,'now','now')").run()
|
||||||
|
const staff = async (roles: string[]) => ({
|
||||||
|
Authorization: 'Bearer ' + (await generateToken('99', 'staff', 0, 'test-key', roles)),
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
await (
|
||||||
|
await SELF.fetch(base + '44', { headers: await staff(['developer', 'betastudio']) })
|
||||||
|
).json()
|
||||||
|
).toMatchObject({ AccountId: 44, StudioAccess: 2, AvatarStudioAccess: 0 })
|
||||||
|
expect(
|
||||||
|
await (await SELF.fetch(base + '42', { headers: await staff(['moderator']) })).json()
|
||||||
|
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 8 })
|
||||||
|
})
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/** Shared persisted Studio authoring state. Existing storefront items remain unchanged. */
|
||||||
|
export const AVATAR_STUDIO_SCHEMA = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface AvatarStudioItem {
|
||||||
|
CustomAvatarItemId: string
|
||||||
|
CreatorAccountId: number
|
||||||
|
OutfitType: number
|
||||||
|
Accessibility: number
|
||||||
|
CurrentSaves: Array<Record<string, unknown>>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
export interface AvatarStudioSave {
|
||||||
|
id: number
|
||||||
|
item_id: string
|
||||||
|
body_type: number
|
||||||
|
unity_asset_id: string
|
||||||
|
source_filename: string
|
||||||
|
source_hash: string
|
||||||
|
thumbnail: string
|
||||||
|
moderation_image: string | null
|
||||||
|
configuration: string
|
||||||
|
description: string | null
|
||||||
|
update_current: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
export async function avatarStudioFlags(db: D1Database, accountId: number): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')
|
||||||
|
.bind(accountId)
|
||||||
|
.first<{ flags: number }>()
|
||||||
|
return row?.flags ?? 0
|
||||||
|
}
|
||||||
|
export const AVATAR_STUDIO_SLOTS = [
|
||||||
|
0, 2, 3, 10, 20, 100, 101, 102, 103, 104, 200, 203, 300, 301, 500, 501, 502, 503, 504, 505, 506,
|
||||||
|
507, 508, 509, 510, 511, 512, 1000,
|
||||||
|
]
|
||||||
|
export function avatarItemAccessBit(outfitType: number): number {
|
||||||
|
return outfitType === 1000 ? 4 : outfitType >= 500 && outfitType <= 512 ? 2 : 1
|
||||||
|
}
|
||||||
|
export async function avatarStudioItem(
|
||||||
|
db: D1Database,
|
||||||
|
id: string
|
||||||
|
): Promise<AvatarStudioItem | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
|
||||||
|
.bind(id)
|
||||||
|
.first<{ data: string }>()
|
||||||
|
return row ? (JSON.parse(row.data) as AvatarStudioItem) : null
|
||||||
|
}
|
||||||
|
export async function avatarStudioSave(
|
||||||
|
db: D1Database,
|
||||||
|
itemId: string,
|
||||||
|
id: number
|
||||||
|
): Promise<AvatarStudioSave | null> {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT * FROM avatar_studio_save WHERE item_id=? AND id=?')
|
||||||
|
.bind(itemId, id)
|
||||||
|
.first<AvatarStudioSave>()
|
||||||
|
}
|
||||||
|
export async function avatarSaveDto(db: D1Database, row: AvatarStudioSave, target = 0) {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT unity_asset_id AS UnityAssetId,target AS Target,version AS Version,filename AS Filename,hash AS Hash,kind FROM unity_asset WHERE unity_asset_id=?'
|
||||||
|
)
|
||||||
|
.bind(row.unity_asset_id)
|
||||||
|
.all<{
|
||||||
|
UnityAssetId: string
|
||||||
|
Target: number
|
||||||
|
Version: number
|
||||||
|
Filename: string
|
||||||
|
Hash: string
|
||||||
|
kind: string
|
||||||
|
}>()
|
||||||
|
const main = results.find((a) => a.Target === target && a.kind === 'main')
|
||||||
|
const material = results.find((a) => a.Target === target && a.kind === 'material')
|
||||||
|
return {
|
||||||
|
CustomAvatarItemSaveId: row.id,
|
||||||
|
CustomAvatarItemId: row.item_id,
|
||||||
|
UnityAssetId: row.unity_asset_id,
|
||||||
|
BodyType: row.body_type,
|
||||||
|
CreatedAt: row.created_at,
|
||||||
|
ModifiedAt: row.created_at,
|
||||||
|
Description: row.description,
|
||||||
|
ThumbnailFileName: row.thumbnail,
|
||||||
|
AdditionalConfiguration: row.configuration,
|
||||||
|
SourceUnityAssetFilename: row.source_filename,
|
||||||
|
SourceUnityAssetHash: row.source_hash,
|
||||||
|
ModerationImageFileName: row.moderation_image,
|
||||||
|
UnityAsset: main?.Filename ?? null,
|
||||||
|
UnityAssetHash: main?.Hash ?? null,
|
||||||
|
UnityAsset2: material?.Filename ?? null,
|
||||||
|
UnityAsset2Hash: material?.Hash ?? null,
|
||||||
|
BakedAssets: results.filter((a) => a.kind !== 'stripped').map(({ kind: _kind, ...a }) => a),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Publish exactly this built revision for its body type, preserving other body types. */
|
||||||
|
export async function publishAvatarSave(
|
||||||
|
db: D1Database,
|
||||||
|
itemId: string,
|
||||||
|
saveId: number,
|
||||||
|
automatic = false,
|
||||||
|
attempt = 0
|
||||||
|
): Promise<boolean> {
|
||||||
|
const item = await avatarStudioItem(db, itemId)
|
||||||
|
const save = await avatarStudioSave(db, itemId, saveId)
|
||||||
|
if (!item || !save) return false
|
||||||
|
const dto = await avatarSaveDto(db, save)
|
||||||
|
// Badge images are uploaded and published directly by the SDK, without a Unity build.
|
||||||
|
if (!dto.UnityAsset && item.OutfitType !== 1000) return false
|
||||||
|
const quest = await avatarSaveDto(db, save, 2)
|
||||||
|
const {
|
||||||
|
SourceUnityAssetFilename: _source,
|
||||||
|
SourceUnityAssetHash: _hash,
|
||||||
|
BakedAssets: _baked,
|
||||||
|
...published
|
||||||
|
} = dto
|
||||||
|
// JSON mutation is conditional on the complete prior snapshot: concurrent body-type
|
||||||
|
// publications cannot overwrite each other or resurrect a deleted item.
|
||||||
|
const prior = JSON.stringify(item)
|
||||||
|
item.CurrentSaves = [
|
||||||
|
...(item.CurrentSaves ?? []).filter((s) => s.BodyType !== save.body_type),
|
||||||
|
{
|
||||||
|
...published,
|
||||||
|
OutfitType: item.OutfitType,
|
||||||
|
QuestUnityAssetHash: quest.UnityAssetHash,
|
||||||
|
QuestUnityAsset2Hash: quest.UnityAsset2Hash,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
item.ModifiedAt = new Date().toISOString()
|
||||||
|
item.ThumbnailImageFilename = save.thumbnail
|
||||||
|
if (item.OutfitType === 1000)
|
||||||
|
item.CustomBadgeMetadata = { RoomId: JSON.parse(save.configuration)?.d?.d?.r ?? 0 }
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)
|
||||||
|
AND (?=0 OR ?=(SELECT MAX(id) FROM avatar_studio_save WHERE item_id=? AND body_type=?))`
|
||||||
|
)
|
||||||
|
.bind(JSON.stringify(item), itemId, prior, automatic ? 1 : 0, saveId, itemId, save.body_type)
|
||||||
|
.run()
|
||||||
|
if (!result.meta.changes && attempt < 5 && (await avatarStudioItem(db, itemId)))
|
||||||
|
return publishAvatarSave(db, itemId, saveId, automatic, attempt + 1)
|
||||||
|
return result.meta.changes > 0
|
||||||
|
}
|
||||||
@@ -31,3 +31,4 @@ export * from './outfits-db'
|
|||||||
export * from './progression-db'
|
export * from './progression-db'
|
||||||
export * from './relationships-db'
|
export * from './relationships-db'
|
||||||
export * from './validation'
|
export * from './validation'
|
||||||
|
export * from './avatar-studio'
|
||||||
|
|||||||
@@ -2450,7 +2450,7 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
|
|||||||
* it.
|
* it.
|
||||||
*/
|
*/
|
||||||
const serializeRoom = (room: Room): string => {
|
const serializeRoom = (room: Room): string => {
|
||||||
const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
|
const { SubRooms: _subRooms, Tags: _tags, Badges: _badges, Stats: stats, ...rest } = room
|
||||||
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2842,10 +2842,33 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
|||||||
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
|
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
|
||||||
if (room.PublishedAt === '') room.PublishedAt = null
|
if (room.PublishedAt === '') room.PublishedAt = null
|
||||||
}
|
}
|
||||||
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)])
|
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms), attachBadges(db, rooms)])
|
||||||
return rooms
|
return rooms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
|
||||||
|
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
||||||
|
if (!rooms.length) return
|
||||||
|
// API owns this table's migration; room deployments can precede that migration.
|
||||||
|
const exists = await db
|
||||||
|
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='custom_avatar_item'")
|
||||||
|
.first()
|
||||||
|
if (!exists) {
|
||||||
|
for (const room of rooms) room.Badges = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"
|
||||||
|
)
|
||||||
|
.bind(JSON.stringify(rooms.map(roomIdOf)))
|
||||||
|
.all<{ data: string; room_id: number }>()
|
||||||
|
for (const room of rooms)
|
||||||
|
room.Badges = rows.results
|
||||||
|
.filter((r) => r.room_id === roomIdOf(room))
|
||||||
|
.map((r) => JSON.parse(r.data))
|
||||||
|
}
|
||||||
|
|
||||||
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
|
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
|
||||||
export async function getSubRoom(
|
export async function getSubRoom(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export {
|
|||||||
generateToken,
|
generateToken,
|
||||||
generateStudioBuildValidationToken,
|
generateStudioBuildValidationToken,
|
||||||
validateStudioBuildValidationToken,
|
validateStudioBuildValidationToken,
|
||||||
|
studioBuildValidationId,
|
||||||
generatePhotonAuthToken,
|
generatePhotonAuthToken,
|
||||||
TOKEN_TTL_SECONDS,
|
TOKEN_TTL_SECONDS,
|
||||||
} from './jwt'
|
} from './jwt'
|
||||||
|
|||||||
+14
-6
@@ -18,7 +18,7 @@ import { GAME_VERSION, gameBuild } from '@repo/domain'
|
|||||||
// last a day so the client doesn't have to refresh it.
|
// last a day so the client doesn't have to refresh it.
|
||||||
export const TOKEN_TTL_SECONDS = 86400
|
export const TOKEN_TTL_SECONDS = 86400
|
||||||
|
|
||||||
/** A build host can check component text, but cannot act as an account. */
|
/** A build host can validate content and its job's grants, but cannot act as an account. */
|
||||||
export async function generateStudioBuildValidationToken(
|
export async function generateStudioBuildValidationToken(
|
||||||
buildId: string,
|
buildId: string,
|
||||||
secret: string
|
secret: string
|
||||||
@@ -39,19 +39,27 @@ export async function validateStudioBuildValidationToken(
|
|||||||
request: Request,
|
request: Request,
|
||||||
secret: string
|
secret: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
return (await studioBuildValidationId(request, secret)) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed job identity for read-only, job-scoped validation routes. */
|
||||||
|
export async function studioBuildValidationId(
|
||||||
|
request: Request,
|
||||||
|
secret: string
|
||||||
|
): Promise<string | null> {
|
||||||
const header = request.headers.get('Authorization') ?? ''
|
const header = request.headers.get('Authorization') ?? ''
|
||||||
if (!header.startsWith('Bearer ')) return false
|
if (!header.startsWith('Bearer ')) return null
|
||||||
try {
|
try {
|
||||||
const payload = await verify(header.slice(7), secret, 'HS256')
|
const payload = await verify(header.slice(7), secret, 'HS256')
|
||||||
return (
|
return payload.sub === undefined &&
|
||||||
payload.sub === undefined &&
|
|
||||||
payload.aud === 'studio-build-validation' &&
|
payload.aud === 'studio-build-validation' &&
|
||||||
payload.scope === 'studio.build.validate' &&
|
payload.scope === 'studio.build.validate' &&
|
||||||
typeof payload.buildId === 'string' &&
|
typeof payload.buildId === 'string' &&
|
||||||
/^[1-9]\d*$/.test(payload.buildId)
|
/^[1-9]\d*$/.test(payload.buildId)
|
||||||
)
|
? (payload.buildId as string)
|
||||||
|
: null
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user