Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@
"uploadSuccess": "素材已加入画布。",
"uploadCanvasInsertFailed": "画布还没准备好,素材未加入。请稍后重试。",
"uploadFailed": "上传素材失败,请重试。",
"snapshotConflict": "这个项目已在其他标签页更新。当前草稿仍保留在页面中,请刷新后再继续保存。",
"noDownloadableAssets": "当前选中项里没有可下载的素材。",
"noAvailableModel": "当前没有可用模型。",
"metadataLoading": "模型配置加载中,请稍后重试。",
Expand Down
5 changes: 5 additions & 0 deletions src/components/beatcanvas/beatcanvas-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,10 @@ export function BeatCanvasShell({
setAllowEmptyProjectSnapshot(false);
}, []);

const handleProjectSnapshotConflict = useCallback(() => {
toast.error(studioT('messages.snapshotConflict'));
}, [studioT]);

useProjectSnapshotLifecycle({
projectId,
projectPath,
Expand All @@ -1216,6 +1220,7 @@ export function BeatCanvasShell({
restoreProjectSnapshot,
createDraftCard,
onEmptyProjectSnapshotSaved: handleEmptyProjectSnapshotSaved,
onProjectSnapshotConflict: handleProjectSnapshotConflict,
});

// Listen for card connector events from the overlay
Expand Down
11 changes: 11 additions & 0 deletions src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
18 changes: 10 additions & 8 deletions src/components/beatcanvas/use-project-snapshot-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function useProjectSnapshotLifecycle({
restoreProjectSnapshot,
createDraftCard,
onEmptyProjectSnapshotSaved,
onProjectSnapshotConflict,
}: {
projectId: string;
projectPath: string;
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -186,7 +187,7 @@ export function useProjectSnapshotLifecycle({
saveQueueRef.current = queuedSave.catch(() => {});
await queuedSave;
},
[onEmptyProjectSnapshotSaved, projectId]
[onEmptyProjectSnapshotSaved, onProjectSnapshotConflict, projectId]
);

useEffect(() => {
Expand Down Expand Up @@ -312,6 +313,7 @@ export function useProjectSnapshotLifecycle({
if (!isCanvasReady || !isHydratedFromProject) return;

const flushPendingSnapshot = () => {
if (snapshotConflictRef.current) return;
const serializedSnapshot =
pendingProjectSnapshotRef.current ??
JSON.stringify(buildProjectSnapshotDocument());
Expand Down
2 changes: 1 addition & 1 deletion src/content/pages/privacy-policy.en.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/content/pages/privacy-policy.zh.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ BeatAPI Workspace 是一个自托管、单用户应用,不包含账号、登

当你生成媒体或上传支持的参考文件时,服务端会把提示词、参数和所选素材发送到已配置的 BeatAPI 接口。BeatAPI 及其底层模型供应商会依据各自的政策与条款处理这些数据。

Provider API Key 只保留在服务端。通过配置弹窗保存的 Key 会写入本地数据库;只有配置了 `CONFIG_ENCRYPTION_KEY` 时才会静态加密
Provider API Key 只保留在你的本地服务端。通过配置弹窗保存的 Key 在写入本地数据库前始终加密;本地 SQLite 安装会生成一个被 Git 忽略的设备密钥,托管环境必须提供 `CONFIG_ENCRYPTION_KEY`。

## 部署者责任

Expand Down
26 changes: 23 additions & 3 deletions src/core/adapters/beatapi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,18 +214,38 @@ 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({
effectType: 1,
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/
);
});

Expand Down
11 changes: 7 additions & 4 deletions src/core/adapters/beatapi-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
Expand Down
19 changes: 16 additions & 3 deletions src/core/effects/beatapi-input-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/
);
});
30 changes: 17 additions & 13 deletions src/core/effects/beatapi-input-upload.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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'
);
}

Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/core/effects/beatapi-media-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading