mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
Add hosted Studio template distribution (#139)
This commit is contained in:
@@ -50,6 +50,22 @@ just migrate -F studio # remote
|
||||
just migrate -F studio -- --local # dev db
|
||||
```
|
||||
|
||||
## Launcher template distribution
|
||||
|
||||
Set `STUDIO_DISTRIBUTION_MANIFEST` to an R2 catalog key to enable the optional
|
||||
PatchKit-compatible `/1/apps/:secret/*` routes. The catalog and ZIP parts are
|
||||
generated by `studio-patch-2026/build_distribution.py`; upload all parts through
|
||||
the existing CDN before publishing the catalog. No template bytes or operator
|
||||
configuration belong in this repository.
|
||||
|
||||
The catalog names the template application's SHA-256 identifier, latest release,
|
||||
HTTPS multipart content URL, file hashes, and download-chunk hashes. Optional
|
||||
legacy summaries supply comparison hashes without making old templates
|
||||
downloadable. The application identifier scopes requests; it is not an access
|
||||
credential. Missing or disabled catalogs return 404. Invalid catalogs or catalogs
|
||||
larger than 8 MiB return 503; responses use `Cache-Control: no-store` so a corrected
|
||||
publication can be retried immediately.
|
||||
|
||||
## Editor build limits
|
||||
|
||||
`GET /cloud-builds/get-rate-limit-config` returns the editor's
|
||||
|
||||
@@ -21,6 +21,8 @@ export type Env = SharedHonoEnv & {
|
||||
CLOUD_BUILD_KEY?: string
|
||||
CLOUD_BUILD_TARGETS?: string
|
||||
CLOUD_BUILD_TEMPLATE_VERSION?: string
|
||||
/** R2 key of an operator-published template catalog; unset disables downloads. */
|
||||
STUDIO_DISTRIBUTION_MANIFEST?: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
type Release = {
|
||||
version: number
|
||||
label: string
|
||||
contentUrl: string
|
||||
partSize: number
|
||||
summary: Record<string, unknown>
|
||||
}
|
||||
type Distribution = {
|
||||
appSecretSha256: string
|
||||
latest: number
|
||||
releases: Release[]
|
||||
legacySummaries?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
const integer = (value: unknown, minimum = 0): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum
|
||||
const hex = (value: unknown, length: number): value is string =>
|
||||
typeof value === 'string' && new RegExp(`^[a-f0-9]{${length}}$`).test(value)
|
||||
|
||||
function validSummary(value: unknown): value is Record<string, unknown> {
|
||||
if (!record(value) || !Array.isArray(value.files)) return false
|
||||
const paths = new Set<string>()
|
||||
return value.files.every((file: unknown) => {
|
||||
if (!record(file) || typeof file.path !== 'string' || !hex(file.hash, 8) || !integer(file.size))
|
||||
return false
|
||||
const path = file.path
|
||||
if (
|
||||
path.includes('\\') ||
|
||||
/[\p{Cc}<>:"|?*]/u.test(path) ||
|
||||
path
|
||||
.split('/')
|
||||
.some((part) => !part || part === '.' || part === '..' || /[. ]$/.test(part)) ||
|
||||
paths.has(path.toLowerCase())
|
||||
)
|
||||
return false
|
||||
paths.add(path.toLowerCase())
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function validCatalog(value: unknown): value is Distribution {
|
||||
if (
|
||||
!record(value) ||
|
||||
!hex(value.appSecretSha256, 64) ||
|
||||
!integer(value.latest, 1) ||
|
||||
!Array.isArray(value.releases) ||
|
||||
value.releases.length === 0
|
||||
)
|
||||
return false
|
||||
const versions = new Set<number>()
|
||||
for (const release of value.releases) {
|
||||
if (
|
||||
!record(release) ||
|
||||
!integer(release.version, 1) ||
|
||||
versions.has(release.version) ||
|
||||
typeof release.label !== 'string' ||
|
||||
!integer(release.partSize, 1) ||
|
||||
typeof release.contentUrl !== 'string' ||
|
||||
!validSummary(release.summary)
|
||||
)
|
||||
return false
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(release.contentUrl)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const summary = release.summary
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash ||
|
||||
summary.compression_method !== 'zip' ||
|
||||
!hex(summary.hash_code, 64) ||
|
||||
!integer(summary.size, 1) ||
|
||||
!record(summary.chunks) ||
|
||||
summary.chunks.size !== release.partSize ||
|
||||
!Array.isArray(summary.chunks.hashes) ||
|
||||
summary.chunks.hashes.length !== Math.ceil(summary.size / release.partSize) ||
|
||||
!summary.chunks.hashes.every((hash: unknown) => hex(hash, 8))
|
||||
)
|
||||
return false
|
||||
versions.add(release.version)
|
||||
}
|
||||
if (!versions.has(value.latest)) return false
|
||||
return (
|
||||
value.legacySummaries === undefined ||
|
||||
(record(value.legacySummaries) &&
|
||||
Object.entries(value.legacySummaries).every(
|
||||
([id, summary]) =>
|
||||
/^[1-9]\d*$/.test(id) &&
|
||||
Number.isSafeInteger(Number(id)) &&
|
||||
!versions.has(Number(id)) &&
|
||||
validSummary(summary)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/** Optional operator-published PatchKit catalog. No template or deployment identity in source. */
|
||||
export default new Hono<App>().get('/1/apps/:secret/*', serve).get('/1/apps/:secret', serve)
|
||||
|
||||
async function serve(c: Context<App>) {
|
||||
c.header('Cache-Control', 'no-store')
|
||||
const key = c.env.STUDIO_DISTRIBUTION_MANIFEST
|
||||
if (!key) return c.notFound()
|
||||
const manifest = await c.env.CDN_ASSETS.get(key)
|
||||
if (!manifest) return c.notFound()
|
||||
if (manifest.size > 8 * 1024 * 1024) return c.body(null, 503)
|
||||
let catalog: unknown
|
||||
try {
|
||||
catalog = await manifest.json()
|
||||
} catch {
|
||||
return c.body(null, 503)
|
||||
}
|
||||
if (!validCatalog(catalog)) return c.body(null, 503)
|
||||
const secret = c.req.param('secret') ?? ''
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
|
||||
const hash = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
if (hash !== catalog.appSecretSha256) return c.notFound()
|
||||
const suffix = new URL(c.req.url).pathname.split('/').slice(4).join('/')
|
||||
const version = (release: Release) => {
|
||||
const contentGuid =
|
||||
typeof release.summary.hash_code === 'string'
|
||||
? release.summary.hash_code
|
||||
: String(release.version)
|
||||
return {
|
||||
id: release.version,
|
||||
label: release.label,
|
||||
changelog: release.label,
|
||||
published: true,
|
||||
draft: false,
|
||||
pending_publish: false,
|
||||
publish_progress: 1,
|
||||
main_executable: 'dummy.exe',
|
||||
main_executable_args: '',
|
||||
ignored_files: [],
|
||||
content_guid: contentGuid,
|
||||
content_meta_guid: contentGuid,
|
||||
patch_links: { incoming: [], outgoing: [] },
|
||||
}
|
||||
}
|
||||
if (suffix === '')
|
||||
return c.json({
|
||||
secret,
|
||||
display_name: 'Rec Room Studio',
|
||||
name: 'Rec Room Studio',
|
||||
platform: 'windows',
|
||||
current_version: catalog.latest,
|
||||
lowest_version_with_diff: 0,
|
||||
use_keys: false,
|
||||
use_token: false,
|
||||
use_password: false,
|
||||
patcher_whitelabel: false,
|
||||
patcher_download_speed_unit: 'bytes',
|
||||
})
|
||||
if (suffix === 'versions/latest/id') return c.json({ id: catalog.latest })
|
||||
if (suffix === 'changelog')
|
||||
return c.json({
|
||||
versions: catalog.releases.map((r) => ({
|
||||
vid: r.version,
|
||||
label: r.label,
|
||||
changelog: r.label,
|
||||
})),
|
||||
})
|
||||
if (suffix === 'versions') return c.json(catalog.releases.map(version))
|
||||
const match =
|
||||
/^versions\/(latest|[1-9]\d*)(?:\/(content_summary|content_urls|diff_summary|diff_urls))?$/.exec(
|
||||
suffix
|
||||
)
|
||||
if (!match) return c.notFound()
|
||||
// File-version comparisons need historical hashes, without offering the stock
|
||||
// template as an installable release or repeatedly returning 404 per file.
|
||||
const legacy = catalog.legacySummaries?.[match[1]!]
|
||||
if (match[2] === 'content_summary' && legacy) return c.json(legacy)
|
||||
if (!match[2] && legacy)
|
||||
return c.json(
|
||||
version({
|
||||
version: Number(match[1]),
|
||||
label: `v${match[1]}`,
|
||||
summary: legacy,
|
||||
partSize: 0,
|
||||
contentUrl: '',
|
||||
})
|
||||
)
|
||||
const release = catalog.releases.find(
|
||||
(r) => r.version === (match[1] === 'latest' ? catalog.latest : Number(match[1]))
|
||||
)
|
||||
if (!release) return c.notFound()
|
||||
if (match[2] === 'content_summary') return c.json(release.summary)
|
||||
if (match[2] === 'diff_summary') {
|
||||
// A full-replacement update, rather than binary deltas. A successful empty
|
||||
// delta list is cached by PatchKit; a 404 is retried for every installed file.
|
||||
const { files, ...summary } = release.summary
|
||||
return c.json({
|
||||
...summary,
|
||||
added_files: (files as Array<{ path: string }>).map((file) => file.path),
|
||||
modified_files: [],
|
||||
unchanged_files: [],
|
||||
removed_files: [],
|
||||
})
|
||||
}
|
||||
if (match[2] === 'content_urls' || match[2] === 'diff_urls')
|
||||
return c.json([{ url: release.contentUrl, part_size: release.partSize }])
|
||||
return c.json(version(release))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import cloudBuilds from './cloud-builds'
|
||||
import distribution from './distribution'
|
||||
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
||||
import { parseMultipart } from './multipart'
|
||||
|
||||
@@ -162,6 +163,7 @@ const app = new Hono<App>()
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
.route('/', cloudBuilds)
|
||||
.route('/', distribution)
|
||||
|
||||
.get('/', async (c) => {
|
||||
return c.text('hello, world!')
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { env, SELF } from 'cloudflare:test'
|
||||
import { beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
const origin = 'https://example.com/1/apps/template-fixture'
|
||||
beforeEach(async () => {
|
||||
env.STUDIO_DISTRIBUTION_MANIFEST = 'distribution/catalog.json'
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('template-fixture'))
|
||||
await env.CDN_ASSETS.put(
|
||||
env.STUDIO_DISTRIBUTION_MANIFEST,
|
||||
JSON.stringify({
|
||||
appSecretSha256: Array.from(new Uint8Array(digest), (b) =>
|
||||
b.toString(16).padStart(2, '0')
|
||||
).join(''),
|
||||
latest: 170,
|
||||
legacySummaries: { '169': { files: [{ path: 'original.tgz', hash: '87654321', size: 80 }] } },
|
||||
releases: [
|
||||
{
|
||||
version: 170,
|
||||
label: 'v170',
|
||||
partSize: 32,
|
||||
contentUrl: 'https://cdn.example.test/template.zip',
|
||||
summary: {
|
||||
hash_code: 'a'.repeat(64),
|
||||
compression_method: 'zip',
|
||||
size: 50,
|
||||
files: [{ path: 'patched.tgz', hash: '12345678', size: 80 }],
|
||||
chunks: { size: 32, hashes: ['12345678', '87654321'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
it('serves the prepatched v170 catalog and complete multipart download contract', async () => {
|
||||
expect(await (await SELF.fetch(origin + '/changelog')).json()).toEqual({
|
||||
versions: [{ vid: 170, label: 'v170', changelog: 'v170' }],
|
||||
})
|
||||
expect(await (await SELF.fetch(origin + '/versions/latest/id')).json()).toEqual({ id: 170 })
|
||||
expect(await (await SELF.fetch(origin)).json()).toMatchObject({
|
||||
current_version: 170,
|
||||
use_keys: false,
|
||||
})
|
||||
expect(await (await SELF.fetch(origin + '/versions/170')).json()).toMatchObject({
|
||||
id: 170,
|
||||
label: 'v170',
|
||||
patch_links: { incoming: [], outgoing: [] },
|
||||
content_guid: 'a'.repeat(64),
|
||||
})
|
||||
expect(await (await SELF.fetch(origin + '/versions/170/content_urls')).json()).toEqual([
|
||||
{ url: 'https://cdn.example.test/template.zip', part_size: 32 },
|
||||
])
|
||||
expect(await (await SELF.fetch(origin + '/versions/170/content_summary')).json()).toMatchObject({
|
||||
compression_method: 'zip',
|
||||
chunks: { hashes: ['12345678', '87654321'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed or unsafe catalogs without caching a broken update response', async () => {
|
||||
const key = env.STUDIO_DISTRIBUTION_MANIFEST!
|
||||
const original = await (await env.CDN_ASSETS.get(key))!.text()
|
||||
for (const change of [
|
||||
(c: any) => {
|
||||
c.latest = 171
|
||||
},
|
||||
(c: any) => {
|
||||
c.releases.push(c.releases[0])
|
||||
},
|
||||
(c: any) => {
|
||||
c.legacySummaries['170'] = c.legacySummaries['169']
|
||||
},
|
||||
(c: any) => {
|
||||
c.releases[0].contentUrl = 'http://cdn.example.test/content.zip'
|
||||
},
|
||||
(c: any) => {
|
||||
c.releases[0].summary.files[0].path = '../outside'
|
||||
},
|
||||
(c: any) => {
|
||||
c.releases[0].summary.files = null
|
||||
},
|
||||
(c: any) => {
|
||||
delete c.releases[0].summary.hash_code
|
||||
},
|
||||
(c: any) => {
|
||||
c.releases[0].summary.chunks.hashes = []
|
||||
},
|
||||
]) {
|
||||
const catalog = JSON.parse(original)
|
||||
change(catalog)
|
||||
await env.CDN_ASSETS.put(key, JSON.stringify(catalog))
|
||||
const response = await SELF.fetch(origin + '/versions/170/diff_summary')
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
||||
}
|
||||
await env.CDN_ASSETS.put(key, '{')
|
||||
expect((await SELF.fetch(origin)).status).toBe(503)
|
||||
await env.CDN_ASSETS.put(key, ' '.repeat(8 * 1024 * 1024 + 1))
|
||||
expect((await SELF.fetch(origin)).status).toBe(503)
|
||||
})
|
||||
it('does not route other applications, missing versions, or disabled catalogs', async () => {
|
||||
expect((await SELF.fetch('https://example.com/1/apps/unrelated/versions/latest/id')).status).toBe(
|
||||
404
|
||||
)
|
||||
expect(await (await SELF.fetch(origin + '/versions/169')).json()).toMatchObject({
|
||||
id: 169,
|
||||
label: 'v169',
|
||||
})
|
||||
expect((await SELF.fetch(origin + '/versions/168')).status).toBe(404)
|
||||
expect((await SELF.fetch(origin + '/versions/169/content_urls')).status).toBe(404)
|
||||
delete env.STUDIO_DISTRIBUTION_MANIFEST
|
||||
expect((await SELF.fetch(origin + '/versions/latest/id')).status).toBe(404)
|
||||
})
|
||||
|
||||
it('provides cached comparison and full-replacement update metadata without legacy downloads', async () => {
|
||||
expect(await (await SELF.fetch(origin + '/versions/169/content_summary')).json()).toMatchObject({
|
||||
files: [{ path: 'original.tgz', hash: '87654321' }],
|
||||
})
|
||||
expect(await (await SELF.fetch(origin + '/versions/170/diff_summary')).json()).toMatchObject({
|
||||
compression_method: 'zip',
|
||||
added_files: ['patched.tgz'],
|
||||
modified_files: [],
|
||||
unchanged_files: [],
|
||||
})
|
||||
expect(await (await SELF.fetch(origin + '/versions/170/diff_urls')).json()).toEqual([
|
||||
{ url: 'https://cdn.example.test/template.zip', part_size: 32 },
|
||||
])
|
||||
})
|
||||
Reference in New Issue
Block a user