mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[studio] add get-build-count-for-user route (#134)
Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
This commit is contained in:
@@ -9,11 +9,12 @@ presence list. It does not serve the bundle bytes. A stored build is loaded from
|
||||
Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`)
|
||||
and from the bucket at `/room/{filename}`.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/cloud-builds/for-room` | Cloud builds for that room and subroom, newest first (`results`, `totalResults`). Query: `roomId`, `subRoomId`, `skip`, `take`. A bad query is an empty page. |
|
||||
| `POST` | `/cloud-builds/from-editor` | Multipart Windows bundle (`windows`) and Android bundle (`android`), optional `windowsStripped` and `androidStripped`. Stores a finished cloud build on the subroom's latest save and sets `BecameRRStudioRoomAt` the first time. Bearer required, and the token must include `betastudio`. Caller must be the creator or a co-owner. |
|
||||
| `GET` | `/collaboration/owners-in-room` | Account ids of the room's co-owners (creator, or a Creator/CoOwner role) whose live presence is in that `roomId` and `subRoomId`. Body: `{ success: true, error: null, value: number[] }`. Bearer required. |
|
||||
| Method | Path | Purpose |
|
||||
| ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/cloud-builds/for-room` | Cloud builds for that room and subroom, newest first (`results`, `totalResults`). Query: `roomId`, `subRoomId`, `skip`, `take`. A bad query is an empty page. |
|
||||
| `GET` | `/cloud-builds/get-build-count-for-user` | Authenticated user's Studio build totals for the current UTC-hour rate-limit window. |
|
||||
| `POST` | `/cloud-builds/from-editor` | Multipart Windows bundle (`windows`) and Android bundle (`android`), optional `windowsStripped` and `androidStripped`. Stores a finished cloud build on the subroom's latest save and sets `BecameRRStudioRoomAt` the first time. Bearer required, and the token must include `betastudio`. Caller must be the creator or a co-owner. |
|
||||
| `GET` | `/collaboration/owners-in-room` | Account ids of the room's co-owners (creator, or a Creator/CoOwner role) whose live presence is in that `roomId` and `subRoomId`. Body: `{ success: true, error: null, value: number[] }`. Bearer required. |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -35,6 +35,16 @@ export interface StoredLocalBuild extends CloudBuildRow {
|
||||
subRoomDataSaveId: number
|
||||
}
|
||||
|
||||
export interface UserBuildCount {
|
||||
accountId: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
subRoomTotalBuildCount: number
|
||||
subRoomFailedBuildCount: number
|
||||
avatarItemTotalBuildCount: number
|
||||
avatarItemFailedBuildCount: number
|
||||
}
|
||||
|
||||
interface BuildListRow {
|
||||
cloud_build_id: string
|
||||
started_at: string
|
||||
@@ -83,6 +93,36 @@ export async function listCloudBuilds(
|
||||
return { results: results.map(toCloudBuild), totalResults: Number(total?.n ?? 0) }
|
||||
}
|
||||
|
||||
/** Build totals for the current UTC hour, matching Studio's rate-limit window DTO. */
|
||||
export async function getUserBuildCount(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
now = new Date()
|
||||
): Promise<UserBuildCount> {
|
||||
const start = new Date(now)
|
||||
start.setUTCMinutes(0, 0, 0)
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed
|
||||
FROM studio_cloud_build
|
||||
WHERE created_by_account_id = ?1
|
||||
AND started_at >= ?2 AND started_at < ?3`
|
||||
)
|
||||
.bind(accountId, start.toISOString(), end.toISOString())
|
||||
.first<{ total: number | string; failed: number | string | null }>()
|
||||
return {
|
||||
accountId,
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
subRoomTotalBuildCount: Number(row?.total ?? 0),
|
||||
subRoomFailedBuildCount: Number(row?.failed ?? 0),
|
||||
avatarItemTotalBuildCount: 0,
|
||||
avatarItemFailedBuildCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Base64 SHA-256 — the encoding `unity_asset.hash` (and every other bundle hash) uses. */
|
||||
async function sha256Base64(bytes: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import { listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
||||
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
||||
import { parseMultipart } from './multipart'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
@@ -195,6 +195,18 @@ const app = new Hono<App>()
|
||||
})
|
||||
)
|
||||
|
||||
// Studio checks the authenticated user's counts before it starts a build. The
|
||||
// 2026 editor treats a missing route as a fatal error and silently stops upload.
|
||||
.get('/cloud-builds/get-build-count-for-user', async (c) => {
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (accountId == null) return c.body(null, 401)
|
||||
return c.json({
|
||||
success: true,
|
||||
error: null,
|
||||
value: await getUserBuildCount(c.env.DB, accountId),
|
||||
})
|
||||
})
|
||||
|
||||
// Who of this room's co-owners is standing in this subroom right now. Studio
|
||||
// shows them as "Co-owners in Room" and reads a Result<List<int>>: `success`
|
||||
// must be true and `error` null, or the editor throws; `value` is those
|
||||
|
||||
@@ -123,6 +123,40 @@ it('serves a stubbed cloud build rate limit config', async () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('requires authentication for the user build count', async () => {
|
||||
const res = await SELF.fetch('https://example.com/cloud-builds/get-build-count-for-user')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('serves the authenticated user build count expected by Studio 2026', async () => {
|
||||
const res = await SELF.fetch('https://example.com/cloud-builds/get-build-count-for-user', {
|
||||
headers: await bearer('11899'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json<{
|
||||
success: boolean
|
||||
error: string | null
|
||||
value: {
|
||||
accountId: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
subRoomTotalBuildCount: number
|
||||
subRoomFailedBuildCount: number
|
||||
avatarItemTotalBuildCount: number
|
||||
avatarItemFailedBuildCount: number
|
||||
}
|
||||
}>()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.error).toBeNull()
|
||||
expect(body.value.accountId).toBe(11899)
|
||||
expect(body.value.subRoomTotalBuildCount).toBe(0)
|
||||
expect(body.value.subRoomFailedBuildCount).toBe(0)
|
||||
expect(body.value.avatarItemTotalBuildCount).toBe(0)
|
||||
expect(body.value.avatarItemFailedBuildCount).toBe(0)
|
||||
expect(Date.parse(body.value.startTime)).not.toBeNaN()
|
||||
expect(Date.parse(body.value.endTime)).not.toBeNaN()
|
||||
})
|
||||
|
||||
describe('GET /collaboration/owners-in-room', () => {
|
||||
const roomId = 1106
|
||||
const subRoomId = 22941
|
||||
|
||||
Reference in New Issue
Block a user