b0b5630a25
Adds a "导出图集" action at the bottom-right of the play canvas that
snapshots the current session into localStorage and opens
/gallery#id=<id> in a new tab — the original play page keeps running
untouched. In parallel, sends the doc to /api/gallery-pack and
downloads the result as a binary .infiplot file the player can send
to a friend.
The snapshot pulls in:
- Every visited scene's image + beat graph + recorded visit trail
- All AI-prefetched alternate scenes (a new resolvedPrefetchesRef in
PlayInner captures each prefetch as it resolves, so abandoned
branches the engine already paid to generate are kept)
- Character names + basePortraitUrl (voice base64 / styleReference
are stripped — they aren't needed for replay)
/gallery is a no-network interactive replay:
- Per-beat advance and per-choice navigation. Picked choices are
highlighted; unpicked choices are clickable when an alternate was
prefetched, greyed otherwise.
- Stack-based navigation for stepping into branches with one-tap
"返回主线" to collapse back to the main path.
- Top-bar batch download for scene images (including unique
AI-prefetched branch scenes, deduped against the main path) and
character portraits. Fetched with a per-file AbortController + 20s
timeout in a small concurrency pool, then clicked serially.
Prevents one slow CDN response from stranding the busy button.
- In-progress hint banner reminding the player to allow the
browser's "multiple downloads" prompt.
- F-key fullscreen with a top toolbar that auto-retracts after the
initial glance and pops back down on cursor approach.
- Per-scene dialogue panel (fa-clock-rotate-left, matching the
in-game history affordance).
- "导入分享文件" entry on the empty/error state — accepts a friend's
.infiplot, posts to /api/gallery-unpack, renders the decrypted doc.
Share-file format (.infiplot):
- AES-256-GCM via Web Crypto (portable to Cloudflare Workers).
- Layout: 4-byte magic "IFPL" + 1-byte version + 12-byte nonce +
ciphertext (includes 16-byte auth tag).
- Key derived from GALLERY_SECRET via SHA-256.
- GCM's auth tag gives tamper-detection for free; any flip in the
ciphertext/nonce surfaces as "文件校验失败" — same error as wrong-key,
so the distinction can't leak server config.
- Stateless: server keeps no record of issued files.
- GALLERY_SECRET unset → /api/gallery-pack returns 503, the play page
silently skips the share-file download, local view still works.
Rotating the secret invalidates every previously-issued file.
Retention: trimGalleryExports keeps only the 2 most recent localStorage
docs; older ones are evicted before each write so quota stays flat
regardless of how many times the player exports. Share files live on
the player's own disk — no retention concern.
Adds 'gallery_export' to the analytics event schema (scene_count only —
no free text).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
51 lines
1.5 KiB
TypeScript
51 lines
1.5 KiB
TypeScript
import { packDoc } from "@/lib/galleryCrypto";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const MAX_DOC_BYTES = 5_000_000;
|
|
|
|
// Encrypt a gallery doc into the shareable `.infiplot` binary format.
|
|
// Stateless: input is the doc string, output is the encrypted bytes — server
|
|
// keeps nothing. The secret must be configured (no insecure fallback).
|
|
export async function POST(req: Request): Promise<Response> {
|
|
const secret = process.env.GALLERY_SECRET;
|
|
if (!secret) {
|
|
return Response.json(
|
|
{ error: "图集分享未启用 (GALLERY_SECRET 未配置)" },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
let docStr: string;
|
|
try {
|
|
const body = (await req.json()) as { docStr?: unknown };
|
|
if (typeof body.docStr !== "string") {
|
|
return Response.json({ error: "Missing docStr" }, { status: 400 });
|
|
}
|
|
docStr = body.docStr;
|
|
} catch {
|
|
return Response.json({ error: "Bad JSON" }, { status: 400 });
|
|
}
|
|
|
|
if (docStr.length > MAX_DOC_BYTES) {
|
|
return Response.json(
|
|
{ error: "图集数据太大,无法打包分享" },
|
|
{ status: 413 },
|
|
);
|
|
}
|
|
|
|
const bytes = await packDoc(docStr, secret);
|
|
// Copy into a fresh ArrayBuffer so TS 5.7's stricter BodyInit typing accepts
|
|
// it (Uint8Array.buffer is ArrayBufferLike, which the BodyInit overloads
|
|
// don't narrow). Cheap — one extra alloc + memcpy of ~50-200KB.
|
|
const ab = new ArrayBuffer(bytes.byteLength);
|
|
new Uint8Array(ab).set(bytes);
|
|
return new Response(ab, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
"Cache-Control": "no-store",
|
|
},
|
|
});
|
|
}
|