fix(share): harden story share and relocate import button

- 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 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-08 08:46:05 +08:00
parent 0abd5f1525
commit 39a7269494
5 changed files with 40 additions and 19 deletions
+8
View File
@@ -13,6 +13,14 @@ export async function POST(req: Request): Promise<Response> {
); );
} }
const contentLength = req.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) {
return Response.json(
{ error: "剧情数据太大,无法打包分享" },
{ status: 413 },
);
}
let docStr: string; let docStr: string;
try { try {
const body = (await req.json()) as { docStr?: unknown }; const body = (await req.json()) as { docStr?: unknown };
+7 -2
View File
@@ -13,6 +13,11 @@ export async function POST(req: Request): Promise<Response> {
); );
} }
const contentLength = req.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_FILE_BYTES) {
return Response.json({ error: "文件太大" }, { status: 413 });
}
let ab: ArrayBuffer; let ab: ArrayBuffer;
try { try {
ab = await req.arrayBuffer(); ab = await req.arrayBuffer();
@@ -29,9 +34,9 @@ export async function POST(req: Request): Promise<Response> {
try { try {
const docStr = await unpackDoc(new Uint8Array(ab), secret); const docStr = await unpackDoc(new Uint8Array(ab), secret);
return Response.json({ docStr }); return Response.json({ docStr });
} catch (e) { } catch {
return Response.json( return Response.json(
{ error: e instanceof Error ? e.message : "解包失败" }, { error: "剧情文件解包失败" },
{ status: 400 }, { status: 400 },
); );
} }
+14 -12
View File
@@ -1406,13 +1406,15 @@ export default function HomePage() {
setStoryImportError("这个剧情文件是空的。"); setStoryImportError("这个剧情文件是空的。");
return; 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("剧情文件太大,无法载入。"); setStoryImportError("剧情文件太大,无法载入。");
return; return;
} }
try { try {
let text: string; let text: string;
if (file.name.toLowerCase().endsWith(".json") || file.type === "application/json") { if (isJson) {
text = await file.text(); text = await file.text();
} else { } else {
const r = await fetch("/api/story-unpack", { const r = await fetch("/api/story-unpack", {
@@ -1551,8 +1553,6 @@ export default function HomePage() {
<i className="fa-solid fa-arrow-right text-xs" /> <i className="fa-solid fa-arrow-right text-xs" />
</button> </button>
</div>
<div className="mt-4 flex flex-col items-center gap-2">
<input <input
ref={storyImportRef} ref={storyImportRef}
type="file" type="file"
@@ -1563,17 +1563,19 @@ export default function HomePage() {
<button <button
type="button" type="button"
onClick={() => storyImportRef.current?.click()} onClick={() => 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"
> >
<i className="fa-solid fa-file-import text-[10px]" /> <i className="fa-solid fa-file-import text-sm" />
· · · <span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-clay-900 px-2 py-1 font-sans text-[11px] text-cream-50 opacity-0 transition-opacity group-hover:opacity-100">
</span>
</button> </button>
{storyImportError && (
<p className="max-w-[520px] text-center text-xs leading-relaxed text-ember-500">
{storyImportError}
</p>
)}
</div> </div>
{storyImportError && (
<p className="mt-2 text-right text-xs leading-relaxed text-ember-500">
{storyImportError}
</p>
)}
{prompt && ( {prompt && (
<p className="mt-2 text-right text-xs text-clay-400"> <p className="mt-2 text-right text-xs text-clay-400">
Enter · Shift+Enter Enter · Shift+Enter
+6 -4
View File
@@ -1173,6 +1173,9 @@ function PlayInner() {
const blobUrl = await getOrCreateBlobUrl(first.scene.imageUrl); const blobUrl = await getOrCreateBlobUrl(first.scene.imageUrl);
lastImageOriginalUrlRef.current = first.scene.imageUrl; lastImageOriginalUrlRef.current = first.scene.imageUrl;
const initialStoryState = first.storyStateAfter ?? imported.storyState;
if (!initialStoryState) throw new Error("剧情分享文件缺少初始剧情记忆,无法载入。");
const initial: Session = { const initial: Session = {
...imported, ...imported,
history: [ history: [
@@ -1182,7 +1185,7 @@ function PlayInner() {
exit: undefined, exit: undefined,
}, },
], ],
storyState: first.storyStateAfter ?? imported.storyState, storyState: initialStoryState,
orientation: sessionOrientation, orientation: sessionOrientation,
}; };
replaySourceRef.current = imported; replaySourceRef.current = imported;
@@ -1375,7 +1378,7 @@ function PlayInner() {
!!byoTtsRef.current, !!byoTtsRef.current,
); );
} }
}, [currentScene?.id, currentBeat?.id, session?.id]); }, [currentScene?.id, session?.id]);
// Abort all in-flight speculative prefetches when the page unmounts, so we // Abort all in-flight speculative prefetches when the page unmounts, so we
// stop paying for background scene/image generation. Empty deps → fires only // stop paying for background scene/image generation. Empty deps → fires only
@@ -1635,8 +1638,7 @@ function PlayInner() {
if (recordedNext && recordedNext !== choice.effect.targetBeatId) { if (recordedNext && recordedNext !== choice.effect.targetBeatId) {
detachRecordedReplay(); detachRecordedReplay();
} }
} } else if (
if (
replaySourceRef.current && replaySourceRef.current &&
!isRecordedReplayLockedAt(currentBeatRef.current) !isRecordedReplayLockedAt(currentBeatRef.current)
) { ) {
+5 -1
View File
@@ -155,7 +155,11 @@ export function parseStoryShareDoc(value: unknown): StoryShareDoc {
if (typeof value.exportedAt !== "number" || !Number.isFinite(value.exportedAt)) { if (typeof value.exportedAt !== "number" || !Number.isFinite(value.exportedAt)) {
throw new Error("剧情分享文件缺少导出时间"); 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("剧情分享文件缺少当前位置"); throw new Error("剧情分享文件缺少当前位置");
} }
if ( if (