feat(web): export interactive gallery + encrypted share file

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>
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-07 12:03:34 +08:00
parent 5acffb6f85
commit b0b5630a25
10 changed files with 1679 additions and 3 deletions
+98
View File
@@ -0,0 +1,98 @@
// Gallery share-file crypto. AES-256-GCM via Web Crypto — same API in Node 22+
// (`globalThis.crypto`) and Cloudflare Workers, so the `runtime = "nodejs"`
// routes still port cleanly to the OpenNext / Cloudflare build later.
//
// Threat model:
// - Confidentiality: scene URLs + dialogue stay opaque to a casual recipient
// who isn't going through our server (can't curl the file and grep prompts).
// - Integrity: GCM's built-in auth tag means flipping any byte in the
// ciphertext or nonce makes subtle.decrypt throw — no separate HMAC needed.
// - NOT a replay defense: anyone with a valid file can replay it forever
// (this is intentional — it's a share-with-a-friend file, not an auth token).
//
// File layout (all big-endian, raw bytes):
// 0..3 "IFPL" magic — lets us refuse anything that's not ours
// 4 version (=1) bumped if the format ever changes
// 5..16 nonce (12 B) random per file; GCM requires non-repeating nonces
// per key (12-B random gives ~2^-32 collision risk at
// ~4B files — way more than this app will ever produce)
// 17.. ciphertext includes the 16-byte GCM auth tag at the end
//
// Key derivation: SHA-256 of the secret. We don't bother with HKDF/scrypt —
// the secret is already high-entropy (deployer-supplied 32+ char string),
// SHA-256 just normalizes it to AES-256's 32-byte key length.
const MAGIC = [0x49, 0x46, 0x50, 0x4c] as const; // "IFPL"
const VERSION = 1;
const NONCE_LEN = 12;
const HEADER_LEN = MAGIC.length + 1 + NONCE_LEN;
async function deriveKey(secret: string): Promise<CryptoKey> {
const material = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(secret),
);
return crypto.subtle.importKey(
"raw",
material,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"],
);
}
export async function packDoc(
docStr: string,
secret: string,
): Promise<Uint8Array> {
const key = await deriveKey(secret);
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
const plaintext = new TextEncoder().encode(docStr);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, plaintext),
);
const out = new Uint8Array(HEADER_LEN + ciphertext.length);
out.set(MAGIC, 0);
out[MAGIC.length] = VERSION;
out.set(nonce, MAGIC.length + 1);
out.set(ciphertext, HEADER_LEN);
return out;
}
export async function unpackDoc(
blob: Uint8Array,
secret: string,
): Promise<string> {
// 16 = minimum ciphertext length (auth tag alone, with empty plaintext)
if (blob.length < HEADER_LEN + 16) {
throw new Error("文件太小,不是合法的图集分享文件");
}
for (let i = 0; i < MAGIC.length; i++) {
if (blob[i] !== MAGIC[i]) {
throw new Error("文件格式不对,不是合法的图集分享文件");
}
}
const version = blob[MAGIC.length];
if (version !== VERSION) {
throw new Error(`图集分享文件版本不被支持: v${version}`);
}
const nonce = blob.slice(MAGIC.length + 1, HEADER_LEN);
const ciphertext = blob.slice(HEADER_LEN);
const key = await deriveKey(secret);
let plaintext: ArrayBuffer;
try {
plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce },
key,
ciphertext,
);
} catch {
// GCM auth tag failure → decryption refuses. Maps tamper + wrong-key both
// here, which is the right behavior: we can't distinguish, and neither
// should leak more than "this file isn't for this server".
throw new Error("文件校验失败:可能被改动过,或来自另一台部署");
}
return new TextDecoder().decode(plaintext);
}