From 39a72694944e962f63382f63aa811ebf57f1b3aa Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Mon, 8 Jun 2026 08:46:05 +0800 Subject: [PATCH] fix(share): harden story share and relocate import button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Content-Length pre-check to story-pack and story-unpack routes to reject oversized payloads before buffering the body - Suppress internal error details in story-unpack catch (was leaking e.message to the client) - Strengthen sceneIndex validation: require non-negative integer - Guard against undefined storyState when replaying shared stories - Fix prefetch regression: remove currentBeat?.id from useEffect deps that was re-triggering all change-scene prefetches on every beat - Fix double detach: use else-if so the second replay detach guard doesn't fire redundantly after the first already detached - Align client file-size limit by format (.json 12MB, .infiplot 13MB) - Move "载入剧情" import button next to "开始" with hover tooltip Co-Authored-By: Claude Opus 4.6 --- app/api/story-pack/route.ts | 8 ++++++++ app/api/story-unpack/route.ts | 9 +++++++-- app/page.tsx | 26 ++++++++++++++------------ app/play/page.tsx | 10 ++++++---- lib/storyShare.ts | 6 +++++- 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/app/api/story-pack/route.ts b/app/api/story-pack/route.ts index b73f7ce..29bb9de 100644 --- a/app/api/story-pack/route.ts +++ b/app/api/story-pack/route.ts @@ -13,6 +13,14 @@ export async function POST(req: Request): Promise { ); } + const contentLength = req.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) { + return Response.json( + { error: "剧情数据太大,无法打包分享" }, + { status: 413 }, + ); + } + let docStr: string; try { const body = (await req.json()) as { docStr?: unknown }; diff --git a/app/api/story-unpack/route.ts b/app/api/story-unpack/route.ts index 5f4a3ba..c0c2e68 100644 --- a/app/api/story-unpack/route.ts +++ b/app/api/story-unpack/route.ts @@ -13,6 +13,11 @@ export async function POST(req: Request): Promise { ); } + const contentLength = req.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_FILE_BYTES) { + return Response.json({ error: "文件太大" }, { status: 413 }); + } + let ab: ArrayBuffer; try { ab = await req.arrayBuffer(); @@ -29,9 +34,9 @@ export async function POST(req: Request): Promise { try { const docStr = await unpackDoc(new Uint8Array(ab), secret); return Response.json({ docStr }); - } catch (e) { + } catch { return Response.json( - { error: e instanceof Error ? e.message : "解包失败" }, + { error: "剧情文件解包失败" }, { status: 400 }, ); } diff --git a/app/page.tsx b/app/page.tsx index 73a2c50..66b52c7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1406,13 +1406,15 @@ export default function HomePage() { setStoryImportError("这个剧情文件是空的。"); return; } - if (file.size > 12_000_000) { + const isJson = file.name.toLowerCase().endsWith(".json") || file.type === "application/json"; + const maxImportBytes = isJson ? 12_000_000 : 13_000_000; + if (file.size > maxImportBytes) { setStoryImportError("剧情文件太大,无法载入。"); return; } try { let text: string; - if (file.name.toLowerCase().endsWith(".json") || file.type === "application/json") { + if (isJson) { text = await file.text(); } else { const r = await fetch("/api/story-unpack", { @@ -1551,8 +1553,6 @@ export default function HomePage() { 开始 - -
storyImportRef.current?.click()} - className="inline-flex items-center gap-2 text-[10px] smallcaps text-clay-500 transition-colors hover:text-ember-500" + className="group absolute right-[-2.25rem] bottom-2 md:bottom-3 inline-flex items-center justify-center rounded-sm border border-clay-900/20 px-2 py-2 md:py-2.5 text-clay-400 transition-colors hover:border-ember-500 hover:text-ember-500" > - - 载 · 入 · 剧 · 情 + + + 载入剧情 + - {storyImportError && ( -

- {storyImportError} -

- )}
+ {storyImportError && ( +

+ {storyImportError} +

+ )} {prompt && (

Enter 发送 · Shift+Enter 换行 diff --git a/app/play/page.tsx b/app/play/page.tsx index 2d52965..1ac9708 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -1173,6 +1173,9 @@ function PlayInner() { const blobUrl = await getOrCreateBlobUrl(first.scene.imageUrl); lastImageOriginalUrlRef.current = first.scene.imageUrl; + const initialStoryState = first.storyStateAfter ?? imported.storyState; + if (!initialStoryState) throw new Error("剧情分享文件缺少初始剧情记忆,无法载入。"); + const initial: Session = { ...imported, history: [ @@ -1182,7 +1185,7 @@ function PlayInner() { exit: undefined, }, ], - storyState: first.storyStateAfter ?? imported.storyState, + storyState: initialStoryState, orientation: sessionOrientation, }; replaySourceRef.current = imported; @@ -1375,7 +1378,7 @@ function PlayInner() { !!byoTtsRef.current, ); } - }, [currentScene?.id, currentBeat?.id, session?.id]); + }, [currentScene?.id, session?.id]); // Abort all in-flight speculative prefetches when the page unmounts, so we // stop paying for background scene/image generation. Empty deps → fires only @@ -1635,8 +1638,7 @@ function PlayInner() { if (recordedNext && recordedNext !== choice.effect.targetBeatId) { detachRecordedReplay(); } - } - if ( + } else if ( replaySourceRef.current && !isRecordedReplayLockedAt(currentBeatRef.current) ) { diff --git a/lib/storyShare.ts b/lib/storyShare.ts index a78c4a5..3d346e8 100644 --- a/lib/storyShare.ts +++ b/lib/storyShare.ts @@ -155,7 +155,11 @@ export function parseStoryShareDoc(value: unknown): StoryShareDoc { if (typeof value.exportedAt !== "number" || !Number.isFinite(value.exportedAt)) { throw new Error("剧情分享文件缺少导出时间"); } - if (!isRecord(value.current) || typeof value.current.sceneIndex !== "number") { + if ( + !isRecord(value.current) || + !Number.isInteger(value.current.sceneIndex) || + (value.current.sceneIndex as number) < 0 + ) { throw new Error("剧情分享文件缺少当前位置"); } if (