Harden render server and import boundaries
This commit is contained in:
1 parent
5cc373cf6b
commit
fae55e3f13
11 files changed
+2400
-54
No files matched your search
+7
-5
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
PhArrowClockwise,
|
||||
PhArrowCounterClockwise,
|
||||
@@ -35,7 +35,6 @@ import SvgLayerPanel from './components/SvgLayerPanel.vue'
|
||||
import Timeline from './components/Timeline.vue'
|
||||
import TooltipHost from './components/TooltipHost.vue'
|
||||
import WelcomeDialog from './components/WelcomeDialog.vue'
|
||||
import TestOverlay from './devtools/TestOverlay.vue'
|
||||
import ruwioLogo from './assets/logo.png'
|
||||
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
|
||||
import { t } from './i18n'
|
||||
@@ -71,6 +70,9 @@ const welcomeOpen = ref(false)
|
||||
const fullscreenOpen = ref(false)
|
||||
const aboutOpen = ref(false)
|
||||
const helpOpen = ref(false)
|
||||
const TestOverlay = import.meta.env.DEV
|
||||
? defineAsyncComponent(() => import('./devtools/TestOverlay.vue'))
|
||||
: null
|
||||
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
|
||||
const DEFAULT_TIMELINE_HEIGHT = 220
|
||||
const MIN_TIMELINE_HEIGHT = 120
|
||||
@@ -422,7 +424,7 @@ function nudgeSelection(dx: number, dy: number): boolean {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
|
||||
e.preventDefault()
|
||||
testsOpen.value = true
|
||||
if (import.meta.env.DEV) testsOpen.value = true
|
||||
return
|
||||
}
|
||||
// TAB toggles the side panels (asset sidebar + properties aside)
|
||||
@@ -610,7 +612,7 @@ async function runDesktopMenuCommand(command: string) {
|
||||
aboutOpen.value = true
|
||||
break
|
||||
case 'test-runner':
|
||||
testsOpen.value = true
|
||||
if (import.meta.env.DEV) testsOpen.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -803,7 +805,7 @@ onBeforeUnmount(() => {
|
||||
</footer>
|
||||
<DocumentSettings v-model="settingsOpen" />
|
||||
<ExportDialog v-model="exportOpen" />
|
||||
<TestOverlay v-model="testsOpen" />
|
||||
<component :is="TestOverlay" v-if="TestOverlay" v-model="testsOpen" />
|
||||
<FullscreenPreview v-model="fullscreenOpen" />
|
||||
<AboutDialog v-model="aboutOpen" />
|
||||
<HelpDialog v-model="helpOpen" />
|
||||
|
||||
+10
-5
@@ -2,6 +2,7 @@ import type { Scene } from '@/types/scene'
|
||||
|
||||
const DEFAULT_SERVER = 'http://localhost:6000'
|
||||
const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER
|
||||
const RENDER_TOKEN = import.meta.env.VITE_RENDER_TOKEN as string | undefined
|
||||
|
||||
export interface ServerRenderOptions {
|
||||
onProgress?: (progress: number) => void
|
||||
@@ -27,7 +28,10 @@ export async function renderOnServer(
|
||||
const baseUrl = SERVER_URL
|
||||
const startRes = await fetch(`${baseUrl}/render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(RENDER_TOKEN ? { Authorization: `Bearer ${RENDER_TOKEN}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scene,
|
||||
options: {
|
||||
@@ -42,18 +46,19 @@ export async function renderOnServer(
|
||||
const body = await startRes.text().catch(() => '')
|
||||
throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
|
||||
}
|
||||
const { jobId } = (await startRes.json()) as { jobId: string }
|
||||
if (!jobId) throw new Error('Server did not return a jobId')
|
||||
const { jobId, accessToken } = (await startRes.json()) as { jobId: string; accessToken: string }
|
||||
if (!jobId || !accessToken) throw new Error('Server did not return job credentials')
|
||||
|
||||
const downloadUrl = await new Promise<string>((resolve, reject) => {
|
||||
const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
|
||||
const jobToken = encodeURIComponent(accessToken)
|
||||
const es = new EventSource(`${baseUrl}/render/${jobId}/events?token=${jobToken}`)
|
||||
const onAbort = () => {
|
||||
try {
|
||||
es.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
fetch(`${baseUrl}/render/${jobId}`, { method: 'DELETE' }).catch(() => {})
|
||||
fetch(`${baseUrl}/render/${jobId}?token=${jobToken}`, { method: 'DELETE' }).catch(() => {})
|
||||
reject(new DOMException('aborted', 'AbortError'))
|
||||
}
|
||||
if (opts.signal) {
|
||||
|
||||
+65
-4
@@ -17,6 +17,11 @@ const SIG_LOCAL = 0x04034b50
|
||||
const SIG_CENTRAL = 0x02014b50
|
||||
const SIG_EOCD = 0x06054b50
|
||||
const VERSION_NEEDED = 20
|
||||
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
|
||||
const MAX_ENTRIES = 1000
|
||||
const MAX_NAME_BYTES = 512
|
||||
const MAX_ENTRY_BYTES = 100 * 1024 * 1024
|
||||
const MAX_TOTAL_ENTRY_BYTES = 200 * 1024 * 1024
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256)
|
||||
@@ -40,12 +45,20 @@ function crc32(data: Uint8Array): number {
|
||||
|
||||
/** Pack a list of file entries into a flat STORE zip byte stream. */
|
||||
export function zipStore(entries: ZipEntry[]): Uint8Array {
|
||||
if (entries.length > MAX_ENTRIES) throw new Error(`ZIP has more than ${MAX_ENTRIES} entries.`)
|
||||
const enc = new TextEncoder()
|
||||
const localChunks: Uint8Array[] = []
|
||||
const centralChunks: Uint8Array[] = []
|
||||
let offset = 0
|
||||
for (const entry of entries) {
|
||||
validateZipName(entry.name)
|
||||
if (entry.data.length > MAX_ENTRY_BYTES) {
|
||||
throw new Error(`ZIP entry ${entry.name} exceeds ${MAX_ENTRY_BYTES} bytes.`)
|
||||
}
|
||||
const nameBytes = enc.encode(entry.name)
|
||||
if (nameBytes.length > MAX_NAME_BYTES) {
|
||||
throw new Error(`ZIP entry name ${entry.name} is too long.`)
|
||||
}
|
||||
const data = entry.data
|
||||
const crc = crc32(data)
|
||||
// Local file header.
|
||||
@@ -119,27 +132,35 @@ export function zipStore(entries: ZipEntry[]): Uint8Array {
|
||||
/** Parse a STORE-only zip stream. Throws on bad signatures, truncated
|
||||
* data, or compressed entries (we don't ship a deflate path). */
|
||||
export function unzipStore(bytes: Uint8Array): ZipEntry[] {
|
||||
if (bytes.length > MAX_ARCHIVE_BYTES) {
|
||||
throw new Error(`ZIP archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`)
|
||||
}
|
||||
if (bytes.length < 22) throw new Error('Not a ZIP archive (too small).')
|
||||
const dec = new TextDecoder()
|
||||
// EOCD is at least 22 bytes and may have up to 65535 bytes of
|
||||
// trailing comment; scan from the end within that window.
|
||||
let eocdOff = -1
|
||||
const minOff = Math.max(0, bytes.length - (22 + 65535))
|
||||
for (let i = bytes.length - 22; i >= minOff; i--) {
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset + i, 4)
|
||||
const dv = view(bytes, i, 4)
|
||||
if (dv.getUint32(0, true) === SIG_EOCD) {
|
||||
eocdOff = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (eocdOff < 0) throw new Error('Not a ZIP archive (EOCD not found).')
|
||||
const edv = new DataView(bytes.buffer, bytes.byteOffset + eocdOff)
|
||||
const edv = view(bytes, eocdOff, 22)
|
||||
const total = edv.getUint16(10, true)
|
||||
if (total > MAX_ENTRIES) throw new Error(`ZIP has more than ${MAX_ENTRIES} entries.`)
|
||||
const centralSize = edv.getUint32(12, true)
|
||||
const centralOff = edv.getUint32(16, true)
|
||||
ensureRange(bytes, centralOff, centralSize, 'ZIP central directory')
|
||||
|
||||
const entries: ZipEntry[] = []
|
||||
let p = centralOff
|
||||
let extractedTotal = 0
|
||||
for (let i = 0; i < total; i++) {
|
||||
const cdv = new DataView(bytes.buffer, bytes.byteOffset + p)
|
||||
const cdv = view(bytes, p, 46)
|
||||
if (cdv.getUint32(0, true) !== SIG_CENTRAL) {
|
||||
throw new Error('Bad central directory entry.')
|
||||
}
|
||||
@@ -149,20 +170,30 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
|
||||
const extraLen = cdv.getUint16(30, true)
|
||||
const commentLen = cdv.getUint16(32, true)
|
||||
const localOff = cdv.getUint32(42, true)
|
||||
if (nameLen > MAX_NAME_BYTES) throw new Error('ZIP entry name is too long.')
|
||||
if (size > MAX_ENTRY_BYTES) throw new Error(`ZIP entry exceeds ${MAX_ENTRY_BYTES} bytes.`)
|
||||
extractedTotal += size
|
||||
if (extractedTotal > MAX_TOTAL_ENTRY_BYTES) {
|
||||
throw new Error(`ZIP extracted data exceeds ${MAX_TOTAL_ENTRY_BYTES} bytes.`)
|
||||
}
|
||||
if (method !== 0) {
|
||||
throw new Error(`Unsupported ZIP compression method ${method} — only STORE is accepted.`)
|
||||
}
|
||||
ensureRange(bytes, p, 46 + nameLen + extraLen + commentLen, 'ZIP central directory entry')
|
||||
const nameBytes = bytes.subarray(p + 46, p + 46 + nameLen)
|
||||
const name = dec.decode(nameBytes)
|
||||
validateZipName(name)
|
||||
// Re-read the local header to skip its variable-length fields
|
||||
// before the file data.
|
||||
const ldv = new DataView(bytes.buffer, bytes.byteOffset + localOff)
|
||||
const ldv = view(bytes, localOff, 30)
|
||||
if (ldv.getUint32(0, true) !== SIG_LOCAL) {
|
||||
throw new Error('Bad local file header.')
|
||||
}
|
||||
const lNameLen = ldv.getUint16(26, true)
|
||||
const lExtraLen = ldv.getUint16(28, true)
|
||||
const dataOff = localOff + 30 + lNameLen + lExtraLen
|
||||
ensureRange(bytes, localOff, 30 + lNameLen + lExtraLen, 'ZIP local file header')
|
||||
ensureRange(bytes, dataOff, size, 'ZIP entry data')
|
||||
const data = bytes.subarray(dataOff, dataOff + size)
|
||||
// Copy so callers get a standalone Uint8Array they can keep.
|
||||
entries.push({ name, data: new Uint8Array(data) })
|
||||
@@ -171,6 +202,36 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
|
||||
return entries
|
||||
}
|
||||
|
||||
function view(bytes: Uint8Array, offset: number, length: number): DataView {
|
||||
ensureRange(bytes, offset, length, 'ZIP data')
|
||||
return new DataView(bytes.buffer, bytes.byteOffset + offset, length)
|
||||
}
|
||||
|
||||
function ensureRange(bytes: Uint8Array, offset: number, length: number, label: string): void {
|
||||
if (
|
||||
!Number.isSafeInteger(offset) ||
|
||||
!Number.isSafeInteger(length) ||
|
||||
offset < 0 ||
|
||||
length < 0 ||
|
||||
offset > bytes.length ||
|
||||
offset + length > bytes.length
|
||||
) {
|
||||
throw new Error(`${label} is outside the archive.`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateZipName(name: string): void {
|
||||
if (
|
||||
!name ||
|
||||
name.startsWith('/') ||
|
||||
name.startsWith('\\') ||
|
||||
name.includes('\\') ||
|
||||
name.split('/').some((part) => part === '..' || part === '')
|
||||
) {
|
||||
throw new Error(`Unsafe ZIP entry name: ${name || '(empty)'}.`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Magic check — `true` when the first four bytes match the ZIP local
|
||||
* file header signature (PK\x03\x04). Used by the project-file
|
||||
* reader to decide between .ruw (zip) and .json (legacy). */
|
||||
|
||||
Reference in new issue
Block a user