From 626d6b3f1e36a6799404c039409fca181e768aa9 Mon Sep 17 00:00:00 2001 From: KKKK Date: Sat, 22 Aug 2026 02:03:24 +0800 Subject: [PATCH] fix: harden local workspace persistence --- .env.example | 4 +- .gitignore | 9 ++ messages/en.json | 1 + messages/zh.json | 1 + .../beatcanvas/beatcanvas-shell.tsx | 5 ++ .../use-project-snapshot-lifecycle.test.ts | 11 +++ .../use-project-snapshot-lifecycle.ts | 18 ++-- src/content/pages/privacy-policy.en.mdx | 2 +- src/content/pages/privacy-policy.zh.mdx | 2 +- src/core/adapters/beatapi-adapter.test.ts | 26 +++++- src/core/adapters/beatapi-adapter.ts | 11 ++- src/core/effects/beatapi-input-upload.test.ts | 19 ++++- src/core/effects/beatapi-input-upload.ts | 30 ++++--- src/core/effects/beatapi-media-url.test.ts | 25 ++++++ src/core/effects/beatapi-media-url.ts | 65 ++++++++++++++ .../effects/media-upload-detection.test.ts | 26 ++++++ src/core/effects/validation.ts | 85 +++++++++++++------ src/core/projects/project-snapshot.test.ts | 36 ++++++++ src/core/projects/project-snapshot.ts | 12 +++ src/core/projects/projects.ts | 74 +++++++++++----- src/core/workspace-storage/provider/s3.ts | 2 - src/lib/crypto.test.ts | 73 ++++++++++++++++ src/lib/crypto.ts | 66 +++++++++++--- src/lib/request-body-limit.test.ts | 12 +++ src/lib/request-body-limit.ts | 9 ++ src/lib/response-body-limit.test.ts | 32 +++++++ src/lib/response-body-limit.ts | 61 +++++++++++++ src/modules/config/service.ts | 10 ++- src/routes/api/app/projects/$projectId.ts | 25 +++++- .../projects/$projectId/assets/$assetId.ts | 9 +- .../app/projects/$projectId/assets/index.ts | 20 ++--- src/routes/api/app/projects/index.ts | 25 +++++- src/routes/api/config/beatapi.ts | 20 ++++- src/routes/api/config/storage.ts | 19 +++-- src/routes/api/effects/generate.ts | 15 +++- src/routes/api/effects/precheck.ts | 15 +++- src/routes/api/storage/upload.ts | 14 ++- 37 files changed, 754 insertions(+), 135 deletions(-) create mode 100644 src/lib/crypto.test.ts create mode 100644 src/lib/response-body-limit.test.ts create mode 100644 src/lib/response-body-limit.ts diff --git a/.env.example b/.env.example index e918428..468abef 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,8 @@ DATABASE_URL=file:data/workspace.db BEATAPI_API_BASE_URL=https://api.beatapi.io BEATAPI_API_KEY= -# Optional: encrypt a key saved from the in-app Provider dialog. -# Generate with: openssl rand -base64 32 +# Optional override for provider-secret encryption. Local SQLite installs +# automatically create data/.workspace-key; hosted deployments must set this. CONFIG_ENCRYPTION_KEY= # Generation recovery diff --git a/.gitignore b/.gitignore index e5ce406..a714c2a 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,15 @@ yarn-error.log* # database /data/ +*.db +*.db-journal +*.db-shm +*.db-wal +*.sqlite +*.sqlite-journal +*.sqlite-shm +*.sqlite-wal +*.sqlite3 src/config/db/schema.ts # npm (project uses pnpm) diff --git a/messages/en.json b/messages/en.json index 64e6460..df62018 100644 --- a/messages/en.json +++ b/messages/en.json @@ -285,6 +285,7 @@ "uploadSuccess": "Asset added to the canvas.", "uploadCanvasInsertFailed": "The canvas is not ready yet. Please try adding the asset again.", "uploadFailed": "Upload failed. Please try again.", + "snapshotConflict": "This project changed in another tab. Your current draft is still open; reload before saving again.", "noDownloadableAssets": "No downloadable assets in the current selection.", "noAvailableModel": "No available model for the current task.", "metadataLoading": "Model metadata is still loading. Please try again shortly.", diff --git a/messages/zh.json b/messages/zh.json index 09328f1..6fd5ab7 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -286,6 +286,7 @@ "uploadSuccess": "素材已加入画布。", "uploadCanvasInsertFailed": "画布还没准备好,素材未加入。请稍后重试。", "uploadFailed": "上传素材失败,请重试。", + "snapshotConflict": "这个项目已在其他标签页更新。当前草稿仍保留在页面中,请刷新后再继续保存。", "noDownloadableAssets": "当前选中项里没有可下载的素材。", "noAvailableModel": "当前没有可用模型。", "metadataLoading": "模型配置加载中,请稍后重试。", diff --git a/src/components/beatcanvas/beatcanvas-shell.tsx b/src/components/beatcanvas/beatcanvas-shell.tsx index 93e18a4..78796d8 100644 --- a/src/components/beatcanvas/beatcanvas-shell.tsx +++ b/src/components/beatcanvas/beatcanvas-shell.tsx @@ -1202,6 +1202,10 @@ export function BeatCanvasShell({ setAllowEmptyProjectSnapshot(false); }, []); + const handleProjectSnapshotConflict = useCallback(() => { + toast.error(studioT('messages.snapshotConflict')); + }, [studioT]); + useProjectSnapshotLifecycle({ projectId, projectPath, @@ -1216,6 +1220,7 @@ export function BeatCanvasShell({ restoreProjectSnapshot, createDraftCard, onEmptyProjectSnapshotSaved: handleEmptyProjectSnapshotSaved, + onProjectSnapshotConflict: handleProjectSnapshotConflict, }); // Listen for card connector events from the overlay diff --git a/src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts b/src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts index 1e8835c..e27f874 100644 --- a/src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts +++ b/src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts @@ -56,3 +56,14 @@ test('snapshot autosave sends explicit authorization before replacing a populate assert.match(source, /allowEmptyProjectSnapshot/); assert.match(source, /allowEmpty,/); }); + +test('snapshot autosave preserves the local draft and stops after a version conflict', () => { + const source = readFileSync( + new URL('./use-project-snapshot-lifecycle.ts', import.meta.url), + 'utf8' + ); + assert.match(source, /snapshotConflictRef\.current = true/); + assert.match(source, /onProjectSnapshotConflict\?\.\(\)/); + assert.equal(source.match(/await sendSaveRequest/g)?.length, 1); + assert.doesNotMatch(source, /let response/); +}); diff --git a/src/components/beatcanvas/use-project-snapshot-lifecycle.ts b/src/components/beatcanvas/use-project-snapshot-lifecycle.ts index c9811d4..18b3e13 100644 --- a/src/components/beatcanvas/use-project-snapshot-lifecycle.ts +++ b/src/components/beatcanvas/use-project-snapshot-lifecycle.ts @@ -94,6 +94,7 @@ export function useProjectSnapshotLifecycle({ restoreProjectSnapshot, createDraftCard, onEmptyProjectSnapshotSaved, + onProjectSnapshotConflict, }: { projectId: string; projectPath: string; @@ -112,6 +113,7 @@ export function useProjectSnapshotLifecycle({ referenceCardIds: string[]; }) => string | null; onEmptyProjectSnapshotSaved?: () => void; + onProjectSnapshotConflict?: () => void; }) { const [isHydratedFromProject, setIsHydratedFromProject] = useState(false); const [isHydratedFromQuery, setIsHydratedFromQuery] = useState(false); @@ -123,10 +125,12 @@ export function useProjectSnapshotLifecycle({ initialProjectSnapshotVersion ); const saveQueueRef = useRef(Promise.resolve()); + const snapshotConflictRef = useRef(false); const saveSerializedSnapshot = useCallback( async (serializedSnapshot: string, allowEmpty: boolean) => { const runSave = async () => { + if (snapshotConflictRef.current) return; if (serializedSnapshot === lastSavedProjectSnapshotRef.current) { if (pendingProjectSnapshotRef.current === serializedSnapshot) { pendingProjectSnapshotRef.current = null; @@ -149,18 +153,15 @@ export function useProjectSnapshotLifecycle({ }), }); - let response = await sendSaveRequest( + const response = await sendSaveRequest( lastSavedProjectSnapshotVersionRef.current ); if (response.status === 409) { const failure = await readSnapshotSaveFailure(response); - if (typeof failure.currentVersion === 'number') { - lastSavedProjectSnapshotVersionRef.current = failure.currentVersion; - response = await sendSaveRequest(failure.currentVersion); - } else { - throw new Error(failure.message); - } + snapshotConflictRef.current = true; + onProjectSnapshotConflict?.(); + throw new Error(failure.message); } if (!response.ok) { @@ -186,7 +187,7 @@ export function useProjectSnapshotLifecycle({ saveQueueRef.current = queuedSave.catch(() => {}); await queuedSave; }, - [onEmptyProjectSnapshotSaved, projectId] + [onEmptyProjectSnapshotSaved, onProjectSnapshotConflict, projectId] ); useEffect(() => { @@ -312,6 +313,7 @@ export function useProjectSnapshotLifecycle({ if (!isCanvasReady || !isHydratedFromProject) return; const flushPendingSnapshot = () => { + if (snapshotConflictRef.current) return; const serializedSnapshot = pendingProjectSnapshotRef.current ?? JSON.stringify(buildProjectSnapshotDocument()); diff --git a/src/content/pages/privacy-policy.en.mdx b/src/content/pages/privacy-policy.en.mdx index 4ff2c7f..168e854 100644 --- a/src/content/pages/privacy-policy.en.mdx +++ b/src/content/pages/privacy-policy.en.mdx @@ -16,7 +16,7 @@ Projects, Canvas and Studio state, generation history, provider settings, and as When you generate media or upload a supported reference file, the server sends the prompt, parameters, and selected media to the configured BeatAPI endpoint. BeatAPI and underlying model providers process that data under their own policies and terms. -Provider API keys remain on the server. A key saved through the Provider dialog is stored in the local database and is encrypted only when `CONFIG_ENCRYPTION_KEY` is configured. +Provider API keys remain on your local server. A key saved through the Provider dialog is always encrypted before it is stored in the local database. Local SQLite installations create an ignored per-install key; hosted installations must provide `CONFIG_ENCRYPTION_KEY`. ## Operator responsibility diff --git a/src/content/pages/privacy-policy.zh.mdx b/src/content/pages/privacy-policy.zh.mdx index d3589e6..8a9d35c 100644 --- a/src/content/pages/privacy-policy.zh.mdx +++ b/src/content/pages/privacy-policy.zh.mdx @@ -16,7 +16,7 @@ BeatAPI Workspace 是一个自托管、单用户应用,不包含账号、登 当你生成媒体或上传支持的参考文件时,服务端会把提示词、参数和所选素材发送到已配置的 BeatAPI 接口。BeatAPI 及其底层模型供应商会依据各自的政策与条款处理这些数据。 -Provider API Key 只保留在服务端。通过配置弹窗保存的 Key 会写入本地数据库;只有配置了 `CONFIG_ENCRYPTION_KEY` 时才会静态加密。 +Provider API Key 只保留在你的本地服务端。通过配置弹窗保存的 Key 在写入本地数据库前始终加密;本地 SQLite 安装会生成一个被 Git 忽略的设备密钥,托管环境必须提供 `CONFIG_ENCRYPTION_KEY`。 ## 部署者责任 diff --git a/src/core/adapters/beatapi-adapter.test.ts b/src/core/adapters/beatapi-adapter.test.ts index d8ca5e0..9c9fa07 100644 --- a/src/core/adapters/beatapi-adapter.test.ts +++ b/src/core/adapters/beatapi-adapter.test.ts @@ -214,6 +214,26 @@ test('validates Motion Control media counts and orientation duration', () => { }), /up to 10 seconds/ ); + assert.deepEqual( + buildBeatApiTaskRequest({ + effectType: 1, + model: 'kling-3-motion-control', + input: { + prompt: 'Dance', + image_urls: ['https://cdn.example.com/character.png?signature=image'], + video_urls: ['https://cdn.example.com/motion.mp4?signature=video'], + }, + }).body, + { + model: 'kling-3-motion-control', + prompt: 'Dance', + images: ['https://cdn.example.com/character.png?signature=image'], + reference_videos: ['https://cdn.example.com/motion.mp4?signature=video'], + resolution: '720p', + character_orientation: 'video', + background_source: 'input_video', + } + ); assert.throws( () => buildBeatApiTaskRequest({ @@ -221,11 +241,11 @@ test('validates Motion Control media counts and orientation duration', () => { model: 'kling-3-motion-control', input: { prompt: 'Dance', - image_urls: ['https://example.com/character.png'], - video_urls: ['https://example.com/motion.mp4'], + image_urls: ['http://127.0.0.1/character.png'], + video_urls: ['https://cdn.example.com/motion.mp4'], }, }), - /connected BeatAPI account/ + /public HTTP\(S\) URLs/ ); }); diff --git a/src/core/adapters/beatapi-adapter.ts b/src/core/adapters/beatapi-adapter.ts index b6267c0..4c780c4 100644 --- a/src/core/adapters/beatapi-adapter.ts +++ b/src/core/adapters/beatapi-adapter.ts @@ -2,7 +2,10 @@ import { z } from 'zod'; import { getBeatCanvasProviderServerConfig } from '@/core/beatcanvas/providers/provider-config'; import { ensureMotionControlInputUrls } from '@/core/effects/beatapi-input-upload'; -import { isOfficialBeatApiMediaUrl } from '@/core/effects/beatapi-media-url'; +import { + isOfficialBeatApiMediaUrl, + isPublicHttpMediaUrl, +} from '@/core/effects/beatapi-media-url'; import { getConfig } from '@/modules/config/service'; import { BaseAdapter, type GenerationResult } from './base-adapter'; @@ -166,11 +169,11 @@ export const buildBeatApiTaskRequest = ({ throw new Error('Kling Motion Control requires exactly one motion video'); } if ( - !isOfficialBeatApiMediaUrl(images[0]) || - !isOfficialBeatApiMediaUrl(input.video_urls[0]) + !isPublicHttpMediaUrl(images[0]) || + !isPublicHttpMediaUrl(input.video_urls[0]) ) { throw new Error( - 'Kling Motion Control inputs must be uploaded through the connected BeatAPI account' + 'Kling Motion Control inputs must use public HTTP(S) URLs' ); } if ( diff --git a/src/core/effects/beatapi-input-upload.test.ts b/src/core/effects/beatapi-input-upload.test.ts index ace57e7..d0c26ec 100644 --- a/src/core/effects/beatapi-input-upload.test.ts +++ b/src/core/effects/beatapi-input-upload.test.ts @@ -48,15 +48,28 @@ test('keeps official BeatAPI input URLs and rehosts generated outputs', async () } }); -test('rejects Motion Control media that is not on the official BeatAPI origin', async () => { +test('accepts public provider URLs without requiring the official BeatAPI origin', async () => { + const publicUrl = 'https://cdn.example.com/character.png?signature=abc'; + assert.equal( + await ensureBeatApiInputUrl({ + url: publicUrl, + kind: 'image', + baseUrl: 'https://api.beatapi.io', + apiKey: 'sk-test', + }), + publicUrl + ); +}); + +test('rejects private Motion Control media URLs', async () => { await assert.rejects( () => ensureBeatApiInputUrl({ - url: 'https://example.com/character.png', + url: 'http://127.0.0.1/character.png', kind: 'image', baseUrl: 'https://api.beatapi.io', apiKey: 'sk-test', }), - /connected BeatAPI account/ + /public HTTP\(S\) URLs/ ); }); diff --git a/src/core/effects/beatapi-input-upload.ts b/src/core/effects/beatapi-input-upload.ts index 63c1ca7..36f7faf 100644 --- a/src/core/effects/beatapi-input-upload.ts +++ b/src/core/effects/beatapi-input-upload.ts @@ -1,11 +1,17 @@ import { isOfficialBeatApiInputUrl, isOfficialBeatApiMediaUrl, + isPublicHttpMediaUrl, } from './beatapi-media-url'; +import { + readResponseBodyWithLimit, + readResponseJsonWithLimit, +} from '@/lib/response-body-limit'; const MAX_MOTION_IMAGE_BYTES = 10 * 1024 * 1024; const MAX_MOTION_VIDEO_BYTES = 100 * 1024 * 1024; const BEATAPI_FILE_TIMEOUT_MS = 120_000; +const MAX_PROVIDER_JSON_BYTES = 1024 * 1024; type MotionControlAssetKind = 'image' | 'video'; @@ -51,7 +57,10 @@ export const uploadBeatApiInputFile = async ({ body: formData, signal: AbortSignal.timeout(BEATAPI_FILE_TIMEOUT_MS), }); - const payload = (await response.json().catch(() => null)) as unknown; + const payload = await readResponseJsonWithLimit( + response, + MAX_PROVIDER_JSON_BYTES + ); if (!response.ok) { const root = asRecord(payload); const error = asRecord(root?.error); @@ -63,7 +72,7 @@ export const uploadBeatApiInputFile = async ({ } const data = asRecord(asRecord(payload)?.data) ?? asRecord(payload); const url = readString(data?.url); - if (!url || !isOfficialBeatApiInputUrl(url)) { + if (!url || !isPublicHttpMediaUrl(url)) { throw new Error('BeatAPI upload response is incomplete'); } return url; @@ -81,9 +90,10 @@ export const ensureBeatApiInputUrl = async ({ apiKey: string; }) => { if (isOfficialBeatApiInputUrl(url)) return url; + if (isPublicHttpMediaUrl(url) && !isOfficialBeatApiMediaUrl(url)) return url; if (!isOfficialBeatApiMediaUrl(url)) { throw new Error( - 'Kling Motion Control inputs must be uploaded through the connected BeatAPI account' + 'Kling Motion Control inputs must use public HTTP(S) URLs' ); } @@ -104,19 +114,13 @@ export const ensureBeatApiInputUrl = async ({ ); } - const blob = await response.blob(); - if (blob.size > maxBytes) { - throw new Error( - kind === 'image' - ? 'Kling Motion Control images must be 10 MB or smaller' - : 'Kling Motion Control videos must be 100 MB or smaller' - ); - } - + const bytes = await readResponseBodyWithLimit(response, maxBytes); const contentType = - blob.type || response.headers.get('content-type')?.split(';')[0]?.trim() || (kind === 'image' ? 'image/png' : 'video/mp4'); + const bodyBuffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(bodyBuffer).set(bytes); + const blob = new Blob([bodyBuffer], { type: contentType }); return uploadBeatApiInputFile({ baseUrl, diff --git a/src/core/effects/beatapi-media-url.test.ts b/src/core/effects/beatapi-media-url.test.ts index f988021..e3eed52 100644 --- a/src/core/effects/beatapi-media-url.test.ts +++ b/src/core/effects/beatapi-media-url.test.ts @@ -4,8 +4,33 @@ import test from 'node:test'; import { isOfficialBeatApiInputUrl, isOfficialBeatApiMediaUrl, + isPublicHttpMediaUrl, } from './beatapi-media-url'; +test('accepts arbitrary public provider media URLs without requiring an official path', () => { + for (const url of [ + 'https://cdn.example.com/custom/result.mp4?signature=abc&expires=123', + 'http://media.example.org/files/result.png#preview', + 'https://8.8.8.8:8443/output.webm', + ]) { + assert.equal(isPublicHttpMediaUrl(url), true, url); + } + + for (const url of [ + 'file:///tmp/result.mp4', + 'javascript:alert(1)', + 'https://user:pass@cdn.example.com/result.mp4', + 'http://localhost/result.mp4', + 'https://127.0.0.1/result.mp4', + 'https://10.1.2.3/result.mp4', + 'https://203.0.113.10/result.mp4', + 'https://[::1]/result.mp4', + 'https://[::ffff:127.0.0.1]/result.mp4', + ]) { + assert.equal(isPublicHttpMediaUrl(url), false, url); + } +}); + test('allows only the official BeatAPI media origin', () => { assert.equal( isOfficialBeatApiMediaUrl( diff --git a/src/core/effects/beatapi-media-url.ts b/src/core/effects/beatapi-media-url.ts index 9ec0312..499806b 100644 --- a/src/core/effects/beatapi-media-url.ts +++ b/src/core/effects/beatapi-media-url.ts @@ -1,5 +1,70 @@ export const OFFICIAL_BEATAPI_MEDIA_HOST = 'media.beatapi.io'; +const PRIVATE_HOST_SUFFIXES = ['.localhost', '.local', '.internal']; + +const isPrivateIpv4 = (hostname: string) => { + const parts = hostname.split('.').map(Number); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return false; + } + const [a, b, c] = parts; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 192 && b === 0 && (c === 0 || c === 2)) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) || + a >= 224 + ); +}; + +const isPrivateIpv6 = (hostname: string) => { + const value = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + return ( + value === '::' || + value === '::1' || + value.startsWith('::ffff:') || + value.startsWith('fc') || + value.startsWith('fd') || + /^fe[89ab]/.test(value) + ); +}; + +/** + * Accept provider-returned public media URLs without coupling integrations to + * a BeatAPI-owned host or path. Literal local/private hosts remain blocked. + */ +export const isPublicHttpMediaUrl = (value: string) => { + try { + const url = new URL(value); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password + ) { + return false; + } + const hostname = url.hostname.toLowerCase(); + return !( + hostname === 'localhost' || + PRIVATE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)) || + isPrivateIpv4(hostname) || + isPrivateIpv6(hostname) + ); + } catch { + return false; + } +}; + export const isOfficialBeatApiMediaUrl = (value: string) => { try { const url = new URL(value); diff --git a/src/core/effects/media-upload-detection.test.ts b/src/core/effects/media-upload-detection.test.ts index 2b3717d..c63fcce 100644 --- a/src/core/effects/media-upload-detection.test.ts +++ b/src/core/effects/media-upload-detection.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { detectUploadedMediaType, + getCanonicalUploadedMediaMimeType, validateUploadedVideoFile, } from './validation'; @@ -42,3 +43,28 @@ test('accepts a supported video extension when MIME type is missing', () => { { ok: true } ); }); + +test('rejects active or conflicting declared MIME types despite safe extensions', () => { + for (const file of [ + { name: 'payload.mp4', type: 'text/html' }, + { name: 'payload.png', type: 'video/mp4' }, + { name: 'payload.mov', type: 'image/png' }, + ]) { + assert.equal(detectUploadedMediaType(file), null); + assert.equal(getCanonicalUploadedMediaMimeType(file), null); + } +}); + +test('canonicalizes safe declared types and generic browser fallbacks', () => { + assert.equal( + getCanonicalUploadedMediaMimeType({ name: 'portrait.jpg', type: 'image/pjpeg' }), + 'image/jpeg' + ); + assert.equal( + getCanonicalUploadedMediaMimeType({ + name: 'motion.webm', + type: 'application/octet-stream', + }), + 'video/webm' + ); +}); diff --git a/src/core/effects/validation.ts b/src/core/effects/validation.ts index 726469d..f3df84b 100644 --- a/src/core/effects/validation.ts +++ b/src/core/effects/validation.ts @@ -104,33 +104,76 @@ const hasAllowedVideoExtension = (fileName?: string) => export type UploadedMediaType = 'image' | 'video'; -export const detectUploadedMediaType = (file: { - type: string; - name?: string; -}): UploadedMediaType | null => { - const mimeType = getNormalizedMimeType(file.type); - +const mediaTypeFromMime = (mimeType: string): UploadedMediaType | null => { if ( ALLOWED_IMAGE_MIME_TYPES.includes( mimeType as (typeof ALLOWED_IMAGE_MIME_TYPES)[number] - ) || - hasAllowedImageExtension(file.name) + ) ) { return 'image'; } - if ( ALLOWED_VIDEO_MIME_TYPES.includes( mimeType as (typeof ALLOWED_VIDEO_MIME_TYPES)[number] - ) || - hasAllowedVideoExtension(file.name) + ) ) { return 'video'; } - return null; }; +const mediaTypeFromExtension = (fileName?: string): UploadedMediaType | null => + hasAllowedImageExtension(fileName) + ? 'image' + : hasAllowedVideoExtension(fileName) + ? 'video' + : null; + +const isGenericUploadMimeType = (mimeType: string) => + !mimeType || mimeType === 'application/octet-stream'; + +export const detectUploadedMediaType = (file: { + type: string; + name?: string; +}): UploadedMediaType | null => { + const mimeType = getNormalizedMimeType(file.type); + const mimeMediaType = mediaTypeFromMime(mimeType); + const extensionMediaType = mediaTypeFromExtension(file.name); + + if (isGenericUploadMimeType(mimeType)) return extensionMediaType; + if (!mimeMediaType) return null; + if (extensionMediaType && extensionMediaType !== mimeMediaType) return null; + return mimeMediaType; +}; + +export const getCanonicalUploadedMediaMimeType = (file: { + type: string; + name?: string; +}): string | null => { + const mediaType = detectUploadedMediaType(file); + if (!mediaType) return null; + + const mimeType = getNormalizedMimeType(file.type); + if (!isGenericUploadMimeType(mimeType)) { + return mimeType === 'image/jpg' || mimeType === 'image/pjpeg' + ? 'image/jpeg' + : mimeType; + } + + const name = file.name?.toLowerCase() ?? ''; + if (mediaType === 'image') { + if (name.endsWith('.png')) return 'image/png'; + if (name.endsWith('.webp')) return 'image/webp'; + return 'image/jpeg'; + } + if (name.endsWith('.webm')) return 'video/webm'; + if (name.endsWith('.mov')) return 'video/quicktime'; + return 'video/mp4'; +}; + +export const isSafeInlineUploadedMediaMimeType = (value: string) => + Boolean(mediaTypeFromMime(getNormalizedMimeType(value))); + export const countPromptCharacters = (prompt: string) => Array.from(prompt).length; @@ -203,14 +246,7 @@ export const validateUploadedImageFile = (file: { }; } - if ( - !ALLOWED_IMAGE_MIME_TYPES.includes( - getNormalizedMimeType( - file.type - ) as (typeof ALLOWED_IMAGE_MIME_TYPES)[number] - ) && - !hasAllowedImageExtension(file.name) - ) { + if (detectUploadedMediaType(file) !== 'image') { return { ok: false, code: 'IMAGE_TYPE_UNSUPPORTED', @@ -235,14 +271,7 @@ export const validateUploadedVideoFile = (file: { }; } - if ( - !ALLOWED_VIDEO_MIME_TYPES.includes( - getNormalizedMimeType( - file.type - ) as (typeof ALLOWED_VIDEO_MIME_TYPES)[number] - ) && - !hasAllowedVideoExtension(file.name) - ) { + if (detectUploadedMediaType(file) !== 'video') { return { ok: false, code: 'VIDEO_TYPE_UNSUPPORTED', diff --git a/src/core/projects/project-snapshot.test.ts b/src/core/projects/project-snapshot.test.ts index a60625e..1406e0c 100644 --- a/src/core/projects/project-snapshot.test.ts +++ b/src/core/projects/project-snapshot.test.ts @@ -3,10 +3,46 @@ import test from 'node:test'; import { normalizeProjectSnapshotDocument, + hasProjectSnapshotVersionConflict, isDestructiveEmptyProjectSnapshot, ProjectSnapshotValidationError, } from './project-snapshot'; +test('requires the current base version before changing a saved snapshot', () => { + assert.equal( + hasProjectSnapshotVersionConflict({ + currentVersion: 4, + baseVersion: 4, + documentChanged: true, + }), + false + ); + assert.equal( + hasProjectSnapshotVersionConflict({ + currentVersion: 4, + baseVersion: 3, + documentChanged: true, + }), + true + ); + assert.equal( + hasProjectSnapshotVersionConflict({ + currentVersion: 4, + baseVersion: undefined, + documentChanged: true, + }), + true + ); + assert.equal( + hasProjectSnapshotVersionConflict({ + currentVersion: 4, + baseVersion: 3, + documentChanged: false, + }), + false + ); +}); + test('detects only non-empty to empty snapshot replacement as destructive', () => { const empty = normalizeProjectSnapshotDocument({ version: 3, cards: [], frames: {} }); const populated = normalizeProjectSnapshotDocument({ diff --git a/src/core/projects/project-snapshot.ts b/src/core/projects/project-snapshot.ts index 436863a..262c44c 100644 --- a/src/core/projects/project-snapshot.ts +++ b/src/core/projects/project-snapshot.ts @@ -17,6 +17,18 @@ export class ProjectSnapshotValidationError extends Error { } } +export const hasProjectSnapshotVersionConflict = ({ + currentVersion, + baseVersion, + documentChanged, +}: { + currentVersion: number; + baseVersion: number | null | undefined; + documentChanged: boolean; +}) => + documentChanged && + (typeof baseVersion !== 'number' || currentVersion !== baseVersion); + export type ProjectSnapshotShapeFrame = { x: number; y: number; diff --git a/src/core/projects/projects.ts b/src/core/projects/projects.ts index e168bc0..efdb015 100644 --- a/src/core/projects/projects.ts +++ b/src/core/projects/projects.ts @@ -5,6 +5,7 @@ import { project, projectCanvasState, userAsset } from '@/config/db/schema'; import { type ProjectSnapshotDocument, createEmptyProjectSnapshot, + hasProjectSnapshotVersionConflict, isDestructiveEmptyProjectSnapshot, normalizeProjectSnapshotDocument, } from '@/core/projects/project-snapshot'; @@ -239,39 +240,72 @@ export const saveProjectSnapshot = async ({ ? JSON.stringify(previousDocument) : null; - if ( - typeof baseVersion === 'number' && - currentState && - currentState.version !== baseVersion && - previousSerialized !== nextSerialized - ) { + const throwVersionConflict = (currentVersion: number) => { const error = new Error('Project snapshot version conflict') as Error & { currentVersion?: number; }; error.name = 'ProjectSnapshotVersionConflict'; - error.currentVersion = currentState.version; + error.currentVersion = currentVersion; throw error; + }; + + if ( + currentState && + hasProjectSnapshotVersionConflict({ + currentVersion: currentState.version, + baseVersion, + documentChanged: previousSerialized !== nextSerialized, + }) + ) { + throwVersionConflict(currentState.version); } const nextVersion = (currentState?.version ?? 0) + 1; const now = new Date(); if (previousSerialized !== nextSerialized) { - await db - .insert(projectCanvasState) - .values({ - projectId, - documentJson: normalizedDocument, - version: nextVersion, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: projectCanvasState.projectId, - set: { + if (currentState) { + const updated = await db + .update(projectCanvasState) + .set({ + documentJson: normalizedDocument, + version: nextVersion, + updatedAt: now, + }) + .where( + and( + eq(projectCanvasState.projectId, projectId), + eq(projectCanvasState.version, baseVersion as number) + ) + ) + .returning({ version: projectCanvasState.version }); + if (updated.length === 0) { + const latest = await db + .select({ version: projectCanvasState.version }) + .from(projectCanvasState) + .where(eq(projectCanvasState.projectId, projectId)) + .limit(1); + throwVersionConflict(latest[0]?.version ?? currentState.version); + } + } else { + const inserted = await db + .insert(projectCanvasState) + .values({ + projectId, documentJson: normalizedDocument, version: nextVersion, updatedAt: now, - }, - }); + }) + .onConflictDoNothing() + .returning({ version: projectCanvasState.version }); + if (inserted.length === 0) { + const latest = await db + .select({ version: projectCanvasState.version }) + .from(projectCanvasState) + .where(eq(projectCanvasState.projectId, projectId)) + .limit(1); + throwVersionConflict(latest[0]?.version ?? 1); + } + } await db .update(project) diff --git a/src/core/workspace-storage/provider/s3.ts b/src/core/workspace-storage/provider/s3.ts index 6ecf973..7d36230 100644 --- a/src/core/workspace-storage/provider/s3.ts +++ b/src/core/workspace-storage/provider/s3.ts @@ -173,13 +173,11 @@ export class S3Provider implements StorageProvider { if (targetPublicUrl) { // Use custom domain if provided url = `${targetPublicUrl.replace(/\/$/, '')}/${key}`; - console.log('uploadFile, public url', url); } else { // For s3mini, we construct the URL manually // Since bucket is included in endpoint, we just append the key const baseUrl = `${targetEndpoint.replace(/\/$/, '')}/${targetBucketName}`; url = `${baseUrl}/${key}`; - console.log('uploadFile, constructed url', url); } return { url, key }; diff --git a/src/lib/crypto.test.ts b/src/lib/crypto.test.ts new file mode 100644 index 0000000..79aaa7d --- /dev/null +++ b/src/lib/crypto.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +import { decryptSecret, encryptSecret, isEncryptedSecret } from './crypto'; + +const restoreEnv = (name: string, value: string | undefined) => { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +}; + +test('encrypts configured provider secrets instead of persisting plaintext', async () => { + const originalKey = process.env.CONFIG_ENCRYPTION_KEY; + try { + process.env.CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key'; + const encrypted = await encryptSecret('beatapi_test_secret'); + assert.equal(isEncryptedSecret(encrypted), true); + assert.notEqual(encrypted, 'beatapi_test_secret'); + assert.equal(await decryptSecret(encrypted), 'beatapi_test_secret'); + } finally { + restoreEnv('CONFIG_ENCRYPTION_KEY', originalKey); + } +}); + +test('fails closed outside local SQLite when no encryption key is configured', async () => { + const originalKey = process.env.CONFIG_ENCRYPTION_KEY; + const originalProvider = process.env.DATABASE_PROVIDER; + try { + delete process.env.CONFIG_ENCRYPTION_KEY; + process.env.DATABASE_PROVIDER = 'd1'; + await assert.rejects( + encryptSecret('must-not-be-plaintext'), + /Secret encryption is unavailable/ + ); + } finally { + restoreEnv('CONFIG_ENCRYPTION_KEY', originalKey); + restoreEnv('DATABASE_PROVIDER', originalProvider); + } +}); + +test('local SQLite creates a per-install key and encrypts without OS-specific setup', () => { + const installDir = mkdtempSync(join(tmpdir(), 'beatapi-workspace-crypto-')); + const moduleUrl = new URL('./crypto.ts', import.meta.url).href; + const tsxImport = import.meta.resolve('tsx'); + const script = ` + const cryptoModule = await import(${JSON.stringify(moduleUrl)}); + const encrypted = await cryptoModule.encryptSecret('local-secret'); + if (!encrypted.startsWith('enc:v1:')) process.exit(2); + if (await cryptoModule.decryptSecret(encrypted) !== 'local-secret') process.exit(3); + `; + + try { + const env: NodeJS.ProcessEnv = { + ...process.env, + DATABASE_PROVIDER: 'sqlite', + }; + delete env.CONFIG_ENCRYPTION_KEY; + const result = spawnSync( + process.execPath, + ['--import', tsxImport, '--input-type=module', '--eval', script], + { cwd: installDir, env, encoding: 'utf8' } + ); + assert.equal(result.status, 0, result.stderr); + const keyPath = join(installDir, 'data', '.workspace-key'); + assert.equal(existsSync(keyPath), true); + assert.ok(readFileSync(keyPath, 'utf8').trim().length >= 32); + } finally { + rmSync(installDir, { recursive: true, force: true }); + } +}); diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index debbb37..6ce0a0c 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -5,12 +5,12 @@ * Workers, and Edge runtimes with no nodejs_compat requirements. * * Encrypted values are self-describing: `enc:v1:`. - * Plain values (no prefix) pass through decryptSecret unchanged, so legacy - * plaintext rows keep working and get encrypted on their next save. + * Plain values (no prefix) pass through decryptSecret unchanged so the config + * service can migrate legacy rows after a verified encrypted write. * - * Key source: CONFIG_ENCRYPTION_KEY env var. When unset, encryption is - * disabled entirely — values are stored as plaintext (the original behavior). - * Already-encrypted rows still decrypt as long as the key stays configured. + * Key source: CONFIG_ENCRYPTION_KEY when explicitly configured; otherwise a + * per-install key is created at data/.workspace-key for local SQLite mode. + * Secret writes fail closed when neither source is available. * * This protects against database-only compromise (leaked backups, SQL * injection dumps). It does NOT protect against a compromised app server — @@ -20,6 +20,9 @@ const ENC_PREFIX = 'enc:v1:'; const IV_LENGTH = 12; const TAG_LENGTH = 16; +const LOCAL_KEY_PATH = 'data/.workspace-key'; + +let cachedEncryptionSecret: string | undefined; function toBase64(bytes: Uint8Array): string { let bin = ''; @@ -41,7 +44,46 @@ async function deriveKey(secret: string): Promise { } function getEncryptionSecret(): string | undefined { - return process.env.CONFIG_ENCRYPTION_KEY || undefined; + const configured = process.env.CONFIG_ENCRYPTION_KEY?.trim(); + if (configured) return configured; + if (cachedEncryptionSecret) return cachedEncryptionSecret; + if ( + process.env.DATABASE_PROVIDER && + process.env.DATABASE_PROVIDER !== 'sqlite' + ) { + return undefined; + } + if (typeof process.getBuiltinModule !== 'function') return undefined; + + const fs = process.getBuiltinModule('node:fs') as typeof import('node:fs'); + const path = process.getBuiltinModule('node:path') as typeof import('node:path'); + const keyPath = path.resolve(LOCAL_KEY_PATH); + fs.mkdirSync(path.dirname(keyPath), { recursive: true, mode: 0o700 }); + + try { + cachedEncryptionSecret = fs.readFileSync(keyPath, 'utf8').trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const generated = toBase64(crypto.getRandomValues(new Uint8Array(32))); + try { + fs.writeFileSync(keyPath, `${generated}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + cachedEncryptionSecret = generated; + } catch (writeError) { + if ((writeError as NodeJS.ErrnoException).code !== 'EEXIST') { + throw writeError; + } + cachedEncryptionSecret = fs.readFileSync(keyPath, 'utf8').trim(); + } + } + + if (!cachedEncryptionSecret) { + throw new Error('Local secret encryption key is empty'); + } + return cachedEncryptionSecret; } export function isEncryptedSecret(value: string): boolean { @@ -49,14 +91,18 @@ export function isEncryptedSecret(value: string): boolean { } /** - * Encrypt a secret for storage. Returns the value unchanged (plaintext) when - * it's empty, already encrypted, or CONFIG_ENCRYPTION_KEY is not configured. + * Encrypt a secret for storage. Secret persistence never falls back to + * plaintext when an encryption key is unavailable. */ export async function encryptSecret(plain: string): Promise { if (!plain || isEncryptedSecret(plain)) return plain; const secret = getEncryptionSecret(); - if (!secret) return plain; + if (!secret) { + throw new Error( + 'Secret encryption is unavailable. Configure CONFIG_ENCRYPTION_KEY.' + ); + } const key = await deriveKey(secret); const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); @@ -78,7 +124,7 @@ export async function encryptSecret(plain: string): Promise { /** * Decrypt a stored value. Plain (non-prefixed) values pass through unchanged. * Returns null when the value is encrypted but cannot be decrypted - * (wrong/rotated/missing CONFIG_ENCRYPTION_KEY) — callers should skip such values. + * (wrong/rotated/missing encryption key) — callers should skip such values. */ export async function decryptSecret(value: string): Promise { if (!isEncryptedSecret(value)) return value; diff --git a/src/lib/request-body-limit.test.ts b/src/lib/request-body-limit.test.ts index a0895de..84d8c84 100644 --- a/src/lib/request-body-limit.test.ts +++ b/src/lib/request-body-limit.test.ts @@ -4,6 +4,7 @@ import test from 'node:test'; import { readRequestBodyWithLimit, readRequestFormDataWithLimit, + readRequestJsonWithLimit, RequestBodyTooLargeError, } from './request-body-limit'; @@ -26,6 +27,17 @@ test('rejects a chunked body as soon as it exceeds the byte limit', async () => ); }); +test('parses JSON only after the request stays within the byte limit', async () => { + const request = new Request('http://127.0.0.1/config', { + method: 'POST', + body: JSON.stringify({ apiKey: 'local-only' }), + }); + + assert.deepEqual(await readRequestJsonWithLimit(request, 1024), { + apiKey: 'local-only', + }); +}); + test('parses multipart data only after the raw body stays within the limit', async () => { const formData = new FormData(); formData.set('projectId', 'project-1'); diff --git a/src/lib/request-body-limit.ts b/src/lib/request-body-limit.ts index 86f08f6..12fee83 100644 --- a/src/lib/request-body-limit.ts +++ b/src/lib/request-body-limit.ts @@ -5,6 +5,8 @@ export class RequestBodyTooLargeError extends Error { } } +export const MAX_WORKSPACE_JSON_REQUEST_BYTES = 64 * 1024; + export async function readRequestBodyWithLimit( request: Request, maxBytes: number @@ -55,6 +57,13 @@ export async function readRequestTextWithLimit( ); } +export async function readRequestJsonWithLimit( + request: Request, + maxBytes: number +): Promise { + return JSON.parse(await readRequestTextWithLimit(request, maxBytes)) as T; +} + export async function readRequestFormDataWithLimit( request: Request, maxBytes: number diff --git a/src/lib/response-body-limit.test.ts b/src/lib/response-body-limit.test.ts new file mode 100644 index 0000000..53acf44 --- /dev/null +++ b/src/lib/response-body-limit.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + readResponseBodyWithLimit, + readResponseJsonWithLimit, + ResponseBodyTooLargeError, +} from './response-body-limit'; + +test('rejects a streamed provider response once it exceeds the limit', async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(6)); + controller.enqueue(new Uint8Array(6)); + controller.close(); + }, + }) + ); + + await assert.rejects( + readResponseBodyWithLimit(response, 10), + ResponseBodyTooLargeError + ); +}); + +test('parses a bounded provider JSON response', async () => { + const response = Response.json({ data: { url: 'https://cdn.example/result.mp4' } }); + assert.deepEqual(await readResponseJsonWithLimit(response, 1024), { + data: { url: 'https://cdn.example/result.mp4' }, + }); +}); diff --git a/src/lib/response-body-limit.ts b/src/lib/response-body-limit.ts new file mode 100644 index 0000000..c97e6b4 --- /dev/null +++ b/src/lib/response-body-limit.ts @@ -0,0 +1,61 @@ +export class ResponseBodyTooLargeError extends Error { + constructor(public readonly maxBytes: number) { + super(`Response body exceeds ${maxBytes} bytes`); + this.name = 'ResponseBodyTooLargeError'; + } +} + +export async function readResponseBodyWithLimit( + response: Response, + maxBytes: number +): Promise { + const declaredLength = response.headers.get('content-length'); + if (declaredLength && /^\d+$/.test(declaredLength)) { + if (Number(declaredLength) > maxBytes) { + await response.body?.cancel(); + throw new ResponseBodyTooLargeError(maxBytes); + } + } + + if (!response.body) return new Uint8Array(); + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel(); + throw new ResponseBodyTooLargeError(maxBytes); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const body = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +export async function readResponseJsonWithLimit( + response: Response, + maxBytes: number +): Promise { + const body = await readResponseBodyWithLimit(response, maxBytes); + if (body.byteLength === 0) return null; + try { + return JSON.parse(new TextDecoder().decode(body)) as unknown; + } catch { + return null; + } +} diff --git a/src/modules/config/service.ts b/src/modules/config/service.ts index 9b48a59..16277c3 100644 --- a/src/modules/config/service.ts +++ b/src/modules/config/service.ts @@ -33,6 +33,13 @@ export async function getDbConfigs(): Promise { continue; } result[row.name] = plain; + } else if (isSecretConfigKey(row.name)) { + const encrypted = await encryptSecret(row.value); + await db() + .update(config) + .set({ value: encrypted }) + .where(eq(config.name, row.name)); + result[row.name] = row.value; } else { result[row.name] = row.value; } @@ -68,7 +75,8 @@ const WRITABLE_CONFIG_KEYS: ReadonlySet = new Set([ ]); /** - * Provider secrets are encrypted at rest when CONFIG_ENCRYPTION_KEY is set. + * Provider secrets are always encrypted at rest. Local SQLite mode generates + * a per-install key; hosted modes require CONFIG_ENCRYPTION_KEY. */ const SECRET_KEY_PATTERN = /(_secret|_secret_key|_token|_password|_private_key|_api_key|_access_key|_access_key_id|_api_v3_key)$/; diff --git a/src/routes/api/app/projects/$projectId.ts b/src/routes/api/app/projects/$projectId.ts index 8d07000..34edc0a 100644 --- a/src/routes/api/app/projects/$projectId.ts +++ b/src/routes/api/app/projects/$projectId.ts @@ -9,6 +9,11 @@ import { renameProject, } from '@/core/projects/projects'; import { validateTrustedWorkspaceJsonMutation } from '@/lib/trusted-local-request'; +import { + MAX_WORKSPACE_JSON_REQUEST_BYTES, + readRequestJsonWithLimit, + RequestBodyTooLargeError, +} from '@/lib/request-body-limit'; type UpdateProjectRequest = { name?: string; @@ -34,8 +39,14 @@ async function POST({ const { projectId } = params; let payload: OpenProjectRequest = {}; try { - payload = (await request.json()) as OpenProjectRequest; - } catch { + payload = await readRequestJsonWithLimit( + request, + MAX_WORKSPACE_JSON_REQUEST_BYTES + ); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return Response.json({ error: 'Request body is too large' }, { status: 413 }); + } return Response.json({ error: 'Invalid JSON' }, { status: 400 }); } const workspaceMode: WorkspaceMode | undefined = payload.workspaceMode @@ -80,8 +91,14 @@ async function PATCH({ let payload: UpdateProjectRequest | null = null; try { - payload = (await request.json()) as UpdateProjectRequest; - } catch { + payload = await readRequestJsonWithLimit( + request, + MAX_WORKSPACE_JSON_REQUEST_BYTES + ); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return Response.json({ error: 'Request body is too large' }, { status: 413 }); + } return Response.json({ error: 'Invalid JSON' }, { status: 400 }); } diff --git a/src/routes/api/app/projects/$projectId/assets/$assetId.ts b/src/routes/api/app/projects/$projectId/assets/$assetId.ts index 005c9ca..af8c9d2 100644 --- a/src/routes/api/app/projects/$projectId/assets/$assetId.ts +++ b/src/routes/api/app/projects/$projectId/assets/$assetId.ts @@ -6,6 +6,7 @@ import { LOCAL_PROJECT_ASSET_PROVIDER, resolveLocalProjectAssetPath, } from '@/core/projects/local-project-assets'; +import { isSafeInlineUploadedMediaMimeType } from '@/core/effects/validation'; import { getProjectAssetById } from '@/core/workspace-lib/assets/user-assets'; const parseByteRange = (header: string | null, size: number) => { @@ -65,12 +66,18 @@ async function GET({ return Response.json({ error: 'Project asset file is missing' }, { status: 404 }); } + const safeInlineMimeType = isSafeInlineUploadedMediaMimeType(asset.mimeType) + ? asset.mimeType + : null; const headers = new Headers({ 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=31536000, immutable', - 'content-type': asset.mimeType || 'application/octet-stream', + 'content-type': safeInlineMimeType || 'application/octet-stream', 'x-content-type-options': 'nosniff', }); + if (!safeInlineMimeType) { + headers.set('content-disposition', 'attachment'); + } const rangeHeader = request.headers.get('range'); const range = parseByteRange(rangeHeader, bytes.byteLength); if (rangeHeader && !range) { diff --git a/src/routes/api/app/projects/$projectId/assets/index.ts b/src/routes/api/app/projects/$projectId/assets/index.ts index 1596ffa..d26bd38 100644 --- a/src/routes/api/app/projects/$projectId/assets/index.ts +++ b/src/routes/api/app/projects/$projectId/assets/index.ts @@ -3,6 +3,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { envConfigs } from '@/config'; import { detectUploadedMediaType, + getCanonicalUploadedMediaMimeType, validateUploadedImageFile, validateUploadedVideoFile, REFERENCE_VIDEO_MAX_FILE_SIZE, @@ -28,20 +29,6 @@ import { validateTrustedWorkspaceMutation } from '@/lib/trusted-local-request'; const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024; -const inferMediaMimeType = (file: File, mediaType: 'image' | 'video') => { - const declared = file.type.trim().toLowerCase(); - if (declared) return declared === 'image/pjpeg' ? 'image/jpeg' : declared; - const name = file.name.toLowerCase(); - if (mediaType === 'image') { - if (name.endsWith('.png')) return 'image/png'; - if (name.endsWith('.webp')) return 'image/webp'; - return 'image/jpeg'; - } - if (name.endsWith('.webm')) return 'video/webm'; - if (name.endsWith('.mov')) return 'video/quicktime'; - return 'video/mp4'; -}; - async function POST({ request, params, @@ -102,7 +89,10 @@ async function POST({ } const bytes = new Uint8Array(await file.arrayBuffer()); - const persistedMimeType = inferMediaMimeType(file, mediaType); + const persistedMimeType = getCanonicalUploadedMediaMimeType(file); + if (!persistedMimeType) { + return Response.json({ error: 'Unsupported project asset type' }, { status: 415 }); + } if ( mediaType === 'image' && !isSupportedRasterImage(persistedMimeType, bytes) diff --git a/src/routes/api/app/projects/index.ts b/src/routes/api/app/projects/index.ts index 7661a33..46ff38c 100644 --- a/src/routes/api/app/projects/index.ts +++ b/src/routes/api/app/projects/index.ts @@ -7,6 +7,11 @@ import { } from '@/core/projects/projects'; import { resolveWorkspaceMode } from '@/config/workspace-modes'; import { validateTrustedWorkspaceJsonMutation } from '@/lib/trusted-local-request'; +import { + MAX_WORKSPACE_JSON_REQUEST_BYTES, + readRequestJsonWithLimit, + RequestBodyTooLargeError, +} from '@/lib/request-body-limit'; type DeleteProjectsRequest = { projectIds?: unknown; @@ -38,8 +43,14 @@ async function POST({ request }: { request: Request }) { let payload: CreateProjectRequest | null = null; try { - payload = (await request.json()) as CreateProjectRequest; - } catch { + payload = await readRequestJsonWithLimit( + request, + MAX_WORKSPACE_JSON_REQUEST_BYTES + ); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return Response.json({ error: 'Request body is too large' }, { status: 413 }); + } return Response.json({ error: 'Invalid JSON' }, { status: 400 }); } @@ -65,8 +76,14 @@ async function DELETE({ request }: { request: Request }) { let payload: DeleteProjectsRequest | null = null; try { - payload = (await request.json()) as DeleteProjectsRequest; - } catch { + payload = await readRequestJsonWithLimit( + request, + MAX_WORKSPACE_JSON_REQUEST_BYTES + ); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return Response.json({ error: 'Request body is too large' }, { status: 413 }); + } return Response.json({ error: 'Invalid JSON' }, { status: 400 }); } diff --git a/src/routes/api/config/beatapi.ts b/src/routes/api/config/beatapi.ts index 4c799ed..9ddc503 100644 --- a/src/routes/api/config/beatapi.ts +++ b/src/routes/api/config/beatapi.ts @@ -4,6 +4,11 @@ import { getConfig, saveConfigs } from '@/modules/config/service'; import { DEFAULT_BEATAPI_BASE_URL } from '@/core/beatcanvas/providers/provider-config'; import { maskApiKeyPreview } from '@/lib/mask-api-key'; import { validateTrustedLocalJsonMutation } from '@/lib/trusted-local-request'; +import { + MAX_WORKSPACE_JSON_REQUEST_BYTES, + readRequestJsonWithLimit, + RequestBodyTooLargeError, +} from '@/lib/request-body-limit'; /** * Workspace-level BeatAPI provider configuration. The dialog pre-fills the @@ -30,9 +35,13 @@ async function POST({ request }: { request: Request }) { if (!trust.ok) { return respErr(trust.message, trust.status); } - const body = (await request.json().catch(() => null)) as { - apiKey?: unknown; - } | null; + const body = await readRequestJsonWithLimit<{ apiKey?: unknown }>( + request, + MAX_WORKSPACE_JSON_REQUEST_BYTES + ).catch((error) => { + if (error instanceof RequestBodyTooLargeError) throw error; + return null; + }); if (!body || typeof body !== 'object') return respErr('Invalid body'); const next: Record = {}; @@ -62,7 +71,10 @@ async function POST({ request }: { request: Request }) { connected: true, apiKeyPreview: maskApiKeyPreview(apiKey), }); - } catch { + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return respErr('Request body is too large', 413); + } return respErr('Internal error', 500); } } diff --git a/src/routes/api/config/storage.ts b/src/routes/api/config/storage.ts index 4cc7f56..fd7c009 100644 --- a/src/routes/api/config/storage.ts +++ b/src/routes/api/config/storage.ts @@ -4,6 +4,11 @@ import { respData, respErr } from '@/lib/resp'; import { getConfig, saveConfigs } from '@/modules/config/service'; import { validateTrustedLocalJsonMutation } from '@/lib/trusted-local-request'; import { validateStorageEndpoint } from '@/core/workspace-storage/endpoint-policy'; +import { + MAX_WORKSPACE_JSON_REQUEST_BYTES, + readRequestJsonWithLimit, + RequestBodyTooLargeError, +} from '@/lib/request-body-limit'; type StorageMode = 'beatapi' | 's3'; @@ -58,8 +63,7 @@ async function POST({ request }: { request: Request }) { return respErr(trust.message, trust.status); } - const body = (await request.json().catch(() => null)) as - | { + const body = await readRequestJsonWithLimit<{ mode?: unknown; region?: unknown; endpoint?: unknown; @@ -68,8 +72,10 @@ async function POST({ request }: { request: Request }) { bucketName?: unknown; publicUrl?: unknown; forcePathStyle?: unknown; - } - | null; + }>(request, MAX_WORKSPACE_JSON_REQUEST_BYTES).catch((error) => { + if (error instanceof RequestBodyTooLargeError) throw error; + return null; + }); if (!body || (body.mode !== 'beatapi' && body.mode !== 's3')) { return respErr('Storage mode must be beatapi or s3', 400); } @@ -169,7 +175,10 @@ async function POST({ request }: { request: Request }) { await saveConfigs(next); return respData({ ok: true, mode }); - } catch { + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return respErr('Request body is too large', 413); + } return respErr('Internal error', 500); } } diff --git a/src/routes/api/effects/generate.ts b/src/routes/api/effects/generate.ts index d4d3c81..b0c8956 100644 --- a/src/routes/api/effects/generate.ts +++ b/src/routes/api/effects/generate.ts @@ -1,6 +1,11 @@ import { createFileRoute } from '@tanstack/react-router'; import { submitEffectGeneration } from '@/core/effects/submit-generation'; import { validateTrustedWorkspaceJsonMutation } from '@/lib/trusted-local-request'; +import { + MAX_WORKSPACE_JSON_REQUEST_BYTES, + readRequestJsonWithLimit, + RequestBodyTooLargeError, +} from '@/lib/request-body-limit'; type GenerateRequest = { effectId?: number; @@ -17,8 +22,14 @@ async function POST({ request }: { request: Request }) { let payload: GenerateRequest; try { - payload = (await request.json()) as GenerateRequest; - } catch { + payload = await readRequestJsonWithLimit( + request, + MAX_WORKSPACE_JSON_REQUEST_BYTES + ); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return Response.json({ error: 'Request body is too large' }, { status: 413 }); + } return Response.json({ error: 'Invalid JSON' }, { status: 400 }); } const result = await submitEffectGeneration({ diff --git a/src/routes/api/effects/precheck.ts b/src/routes/api/effects/precheck.ts index 56d59e5..8bb558a 100644 --- a/src/routes/api/effects/precheck.ts +++ b/src/routes/api/effects/precheck.ts @@ -20,6 +20,11 @@ import { import { getConfig } from '@/modules/config/service'; import { enforceMinIntervalRateLimit } from '@/lib/rate-limit'; import { validateTrustedWorkspaceJsonMutation } from '@/lib/trusted-local-request'; +import { + MAX_WORKSPACE_JSON_REQUEST_BYTES, + readRequestJsonWithLimit, + RequestBodyTooLargeError, +} from '@/lib/request-body-limit'; type PrecheckRequest = { effectId?: number; @@ -36,8 +41,14 @@ async function POST({ request }: { request: Request }) { let payload: PrecheckRequest; try { - payload = (await request.json()) as PrecheckRequest; - } catch { + payload = await readRequestJsonWithLimit( + request, + MAX_WORKSPACE_JSON_REQUEST_BYTES + ); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return Response.json({ error: 'Request body is too large' }, { status: 413 }); + } return Response.json({ error: 'Invalid JSON' }, { status: 400 }); } const effectId = payload.effectId ?? Number.NaN; diff --git a/src/routes/api/storage/upload.ts b/src/routes/api/storage/upload.ts index 4467f17..449b964 100644 --- a/src/routes/api/storage/upload.ts +++ b/src/routes/api/storage/upload.ts @@ -16,11 +16,15 @@ import { readRequestFormDataWithLimit, RequestBodyTooLargeError, } from '@/lib/request-body-limit'; +import { readResponseJsonWithLimit } from '@/lib/response-body-limit'; +import { isPublicHttpMediaUrl } from '@/core/effects/beatapi-media-url'; const MAX_DEFAULT_FILE_BYTES = 50 * 1024 * 1024; const MAX_MOTION_IMAGE_BYTES = 10 * 1024 * 1024; const MAX_VIDEO_FILE_BYTES = 100 * 1024 * 1024; const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024; +const MAX_PROVIDER_JSON_BYTES = 1024 * 1024; +const BEATAPI_UPLOAD_TIMEOUT_MS = 120_000; const BEATAPI_UPLOAD_TYPES = new Set([ 'image/png', 'image/jpeg', @@ -73,9 +77,13 @@ async function uploadToBeatApi(file: File) { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, body: formData, + signal: AbortSignal.timeout(BEATAPI_UPLOAD_TIMEOUT_MS), } ); - const payload = (await response.json().catch(() => null)) as unknown; + const payload = await readResponseJsonWithLimit( + response, + MAX_PROVIDER_JSON_BYTES + ); if (!response.ok) { throw new Error(readErrorMessage(payload) || 'BeatAPI file upload failed'); } @@ -86,7 +94,9 @@ async function uploadToBeatApi(file: File) { const url = typeof data?.url === 'string' ? data.url : null; const key = typeof data?.key === 'string' ? data.key : null; const id = typeof data?.id === 'string' ? data.id : key; - if (!url || !id) throw new Error('BeatAPI upload response is incomplete'); + if (!url || !id || !isPublicHttpMediaUrl(url)) { + throw new Error('BeatAPI upload response is incomplete'); + } return { url, key: key || id, provider: 'beatapi' as const }; }