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:
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user