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
+11
View File
@@ -124,3 +124,14 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=
# domain. Comma-separated, exact match: apex ≠ www (list both), no wildcards. # domain. Comma-separated, exact match: apex ≠ www (list both), no wildcards.
# Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com # Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com
NEXT_PUBLIC_UMAMI_DOMAINS= NEXT_PUBLIC_UMAMI_DOMAINS=
# ---- 7. Gallery share files (optional — leave blank to disable) ----
# Server-side secret used to AES-256-GCM encrypt a played session into a
# binary `.infiplot` share file the player can send to a friend. Friends drop
# the file into /gallery; the server decrypts and renders the same interactive
# replay. GCM's built-in auth tag also gives tamper-detection for free.
# Blank → "导出分享文件" is hidden, only the same-browser localStorage flow
# remains. Set to any high-entropy string ≥ 32 chars (e.g. `openssl rand -hex 32`).
# WARNING: rotating this secret invalidates every share file ever issued
# (decryption will fail with "文件校验失败"). Only change when you're OK with that.
GALLERY_SECRET=
+1 -1
View File
@@ -126,7 +126,7 @@ docker compose up -d
## 团队与愿景 ## 团队与愿景
我们是一群来自清华大学等高校的年轻人。 我们是一群来自清华大学、兰州大学、西安交通大学等高校的年轻人。
一方面,我们本来就是galgame、乙女游戏、FMV、AI角色扮演游戏这类游戏的深度用户,在享受游戏体验的同时,也会想象如果能选择不被预设的剧情选项,或者和对话的AI角色深度互动而不只是通过聊天软件聊天,该是多么愉快刺激的体验。 一方面,我们本来就是galgame、乙女游戏、FMV、AI角色扮演游戏这类游戏的深度用户,在享受游戏体验的同时,也会想象如果能选择不被预设的剧情选项,或者和对话的AI角色深度互动而不只是通过聊天软件聊天,该是多么愉快刺激的体验。
+50
View File
@@ -0,0 +1,50 @@
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",
},
});
}
+46
View File
@@ -0,0 +1,46 @@
import { unpackDoc } from "@/lib/galleryCrypto";
export const runtime = "nodejs";
// Cap a bit above pack's MAX_DOC_BYTES — ciphertext adds the 16-byte GCM tag
// and the 17-byte header; some slack accommodates near-cap docs without
// rejecting them at unpack time.
const MAX_FILE_BYTES = 6_000_000;
// Decrypt a `.infiplot` share file back to its doc JSON string. Returns the
// plaintext as a JSON field (not raw bytes) so the client can chain it through
// JSON.parse without sniffing the response type. Errors are deliberately
// generic — we don't distinguish "wrong key" from "tampered file" because the
// distinction would leak server config.
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 ab: ArrayBuffer;
try {
ab = await req.arrayBuffer();
} catch {
return Response.json({ error: "Bad request body" }, { status: 400 });
}
if (ab.byteLength > MAX_FILE_BYTES) {
return Response.json({ error: "文件太大" }, { status: 413 });
}
if (ab.byteLength === 0) {
return Response.json({ error: "文件为空" }, { status: 400 });
}
try {
const docStr = await unpackDoc(new Uint8Array(ab), secret);
return Response.json({ docStr });
} catch (e) {
return Response.json(
{ error: e instanceof Error ? e.message : "解包失败" },
{ status: 400 },
);
}
}
+1252
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1605,7 +1605,7 @@ export default function HomePage() {
<div> <div>
<p className="text-[10px] smallcaps text-clay-500 mb-3"> </p> <p className="text-[10px] smallcaps text-clay-500 mb-3"> </p>
<p className="font-serif italic text-clay-700 text-base leading-relaxed"> <p className="font-serif italic text-clay-700 text-base leading-relaxed">
<span className="not-italic">one-shot</span> 西 <span className="not-italic">one-shot</span>
</p> </p>
</div> </div>
+206 -1
View File
@@ -16,6 +16,7 @@ import {
type Phase, type Phase,
} from "@/components/PlayCanvas"; } from "@/components/PlayCanvas";
import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal"; import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal";
import type { GalleryDoc, GalleryScene } from "@/app/gallery/page";
import { TtsKeyModal } from "@/components/TtsKeyModal"; import { TtsKeyModal } from "@/components/TtsKeyModal";
import { annotateClick } from "@/lib/annotateClient"; import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
@@ -367,6 +368,14 @@ function findSoleChangeSceneChoice(scene: Scene): BeatChoice | null {
function prefetchScenePath( function prefetchScenePath(
pool: Map<string, PrefetchEntry>, pool: Map<string, PrefetchEntry>,
// Resolved-prefetch sink for the gallery export. Every successful resolve
// is recorded here keyed by `${parentSceneId}:${choiceId}` so the gallery
// can let the player click any choice whose alternate the AI already paid
// to generate — even ones that were later abandoned mid-play because the
// player took a different branch. Survives `consumeChoice`'s abort sweep:
// a prefetch that's already resolved when its parent choice is abandoned
// still leaves the result here.
resolvedSink: Map<string, Scene>,
baseSession: Session, baseSession: Session,
steps: ScenePathStep[], steps: ScenePathStep[],
depth: number, depth: number,
@@ -393,6 +402,16 @@ function prefetchScenePath(
} }
const data = (await res.json()) as SceneResponse; const data = (await res.json()) as SceneResponse;
// Record this resolved alternate for the gallery export. Key is
// (parent scene id at the choice point) : (choice id). Includes the
// CDN imageUrl on the Scene so the gallery has everything it needs to
// render without any further info from the engine.
const lastStep = steps[steps.length - 1]!;
resolvedSink.set(`${lastStep.fromScene.id}:${lastStep.exit.choiceId}`, {
...data.scene,
imageUrl: data.imageUrl,
});
// Kick off the blob fetch for this URL so when the player eventually // Kick off the blob fetch for this URL so when the player eventually
// picks this choice, transitioning is a no-op cache lookup instead of a // picks this choice, transitioning is a no-op cache lookup instead of a
// fresh CDN download. Don't await — let it run in the background; the // fresh CDN download. Don't await — let it run in the background; the
@@ -431,6 +450,7 @@ function prefetchScenePath(
}; };
prefetchScenePath( prefetchScenePath(
pool, pool,
resolvedSink,
carriedBase, carriedBase,
[...steps, nextStep], [...steps, nextStep],
depth + 1, depth + 1,
@@ -564,6 +584,12 @@ function PlayInner() {
const startedRef = useRef(false); const startedRef = useRef(false);
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map()); const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
// Accumulator for resolved prefetches across the whole session — every
// `prefetchScenePath` resolution writes here, keyed by parent-scene + choice.
// Survives `consumeChoice`'s pool sweep (an already-resolved promise is not
// un-resolved by aborting its controller), so abandoned alternates remain
// available for the gallery export. Cleared only on unmount.
const resolvedPrefetchesRef = useRef<Map<string, Scene>>(new Map());
// Lazy per-beat audio fetches keyed by beat.id. Aborted when the scene // Lazy per-beat audio fetches keyed by beat.id. Aborted when the scene
// changes so stale in-flight requests can't poison the new scene's map // changes so stale in-flight requests can't poison the new scene's map
// (beat ids like "b1" are scene-local and would collide across scenes). // (beat ids like "b1" are scene-local and would collide across scenes).
@@ -850,6 +876,164 @@ function PlayInner() {
[prefetchSceneAudio], [prefetchSceneAudio],
); );
// ── Export to interactive gallery (PPT-style replay) ─────────────────
// Drop all but the (keepCount) most-recent gallery exports from localStorage,
// ordered by their stored createdAt. Called right before writing a new
// export so the cap is enforced strictly (≤ keepCount + 1 transiently → ≤ N
// once write completes). Corrupt entries (un-parseable / no createdAt) sort
// last and get evicted first.
const trimGalleryExports = useCallback((keepCount: number) => {
try {
const prefix = "infiplot:gallery:";
const entries: { key: string; createdAt: number }[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (!k || !k.startsWith(prefix)) continue;
let createdAt = 0;
try {
const raw = window.localStorage.getItem(k);
if (raw) {
const parsed = JSON.parse(raw) as { createdAt?: number };
createdAt = parsed.createdAt ?? 0;
}
} catch {
createdAt = 0;
}
entries.push({ key: k, createdAt });
}
entries.sort((a, b) => b.createdAt - a.createdAt);
for (const e of entries.slice(keepCount)) {
window.localStorage.removeItem(e.key);
}
} catch {
// best-effort — quota or disabled storage shouldn't block the export
}
}, []);
// Strips the live Session to a small GalleryDoc — only scene images +
// dialogue text + recorded choices, no voice base64 / portraits / style
// reference (those are tens-to-hundreds of KB each). Writes it to
// localStorage under a one-shot id and opens /gallery#<id> in a new tab
// so the play session keeps running.
const handleExportGallery = useCallback(() => {
const s = sessionRef.current;
if (!s) return;
const scenes: GalleryScene[] = s.history
.map((h) => ({
id: h.scene.id,
imageUrl: h.scene.imageUrl ?? "",
sceneKey: h.scene.sceneKey,
orientation: h.scene.orientation,
beats: h.scene.beats,
entryBeatId: h.scene.entryBeatId,
visitedBeatIds: h.visitedBeatIds,
exit: h.exit,
}))
.filter((sc) => sc.imageUrl);
if (scenes.length === 0) return;
// Alternates: ${parentSceneId}:${choiceId} → reachable scene. Two sources,
// merged with main-path winning ties (it always agrees with prefetch when
// prefetch was actually used, so the override is a no-op in the common case;
// it differs only when the player took a cold path and the prefetch had
// resolved to something the engine later regenerated):
// 1. Every resolved prefetch (including alternates the player never took)
// 2. Main path: every history step's choice exit → the next visited scene
const alternates: Record<string, GalleryScene> = {};
for (const [key, scene] of resolvedPrefetchesRef.current) {
if (!scene.imageUrl) continue;
alternates[key] = {
id: scene.id,
imageUrl: scene.imageUrl,
sceneKey: scene.sceneKey,
orientation: scene.orientation,
beats: scene.beats,
entryBeatId: scene.entryBeatId,
};
}
for (let i = 0; i < s.history.length - 1; i++) {
const h = s.history[i]!;
const nextH = s.history[i + 1]!;
if (
h.exit?.kind === "choice" &&
h.scene.id &&
nextH.scene.imageUrl
) {
alternates[`${h.scene.id}:${h.exit.choiceId}`] = {
id: nextH.scene.id,
imageUrl: nextH.scene.imageUrl,
sceneKey: nextH.scene.sceneKey,
orientation: nextH.scene.orientation,
beats: nextH.scene.beats,
entryBeatId: nextH.scene.entryBeatId,
};
}
}
// Character portraits — names + CDN URLs only. The big voice base64s are
// intentionally dropped (the gallery only needs the portraits for download).
const characters = s.characters
.filter((c) => c.basePortraitUrl)
.map((c) => ({
name: c.name,
basePortraitUrl: c.basePortraitUrl as string,
}));
const id = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
const doc: GalleryDoc = {
v: 2,
id,
createdAt: Date.now(),
orientation: s.orientation ?? "landscape",
scenes,
alternates,
characters,
};
// Cap retained gallery exports at the most recent 2. Drop everything
// older BEFORE writing the new doc so we never transiently exceed the cap
// (and so a near-quota localStorage has headroom for the new entry).
trimGalleryExports(1);
const docStr = JSON.stringify(doc);
try {
window.localStorage.setItem(`infiplot:gallery:${id}`, docStr);
} catch {
// localStorage full or disabled — silently bail; the player keeps playing.
return;
}
track("gallery_export", { scene_count: scenes.length });
window.open(`/gallery#id=${id}`, "_blank", "noopener");
// Fire-and-forget: also pack an encrypted `.infiplot` share file for the
// player to send to a friend. The local-tab view above is instant either
// way; this happens in the background. Server returns 503 if
// GALLERY_SECRET isn't configured, in which case we silently skip — the
// local view still works, just no share file.
void (async () => {
try {
const r = await fetch("/api/gallery-pack", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ docStr }),
});
if (!r.ok) return;
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `infiplot-${id}.infiplot`;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch {
// network / decrypt error — local view above already worked
}
})();
}, [trimGalleryExports]);
// ── Presentation mode toggle ───────────────────────────────────────── // ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => { const togglePresentation = useCallback(async () => {
const entering = !presentation; const entering = !presentation;
@@ -1073,7 +1257,14 @@ function PlayInner() {
nextSceneSeed: choice.effect.nextSceneSeed, nextSceneSeed: choice.effect.nextSceneSeed,
}, },
}; };
prefetchScenePath(poolRef.current, s, [step], 0, !!byoTtsRef.current); prefetchScenePath(
poolRef.current,
resolvedPrefetchesRef.current,
s,
[step],
0,
!!byoTtsRef.current,
);
} }
}, [currentScene?.id, session?.id]); }, [currentScene?.id, session?.id]);
@@ -1521,6 +1712,20 @@ function PlayInner() {
F · · · F · · ·
</button> </button>
} }
belowCanvas={
session && session.history.length > 0 ? (
<button
type="button"
onClick={handleExportGallery}
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
aria-label="导出可交互图集"
title="导出本局为可交互图集链接(只会保留最近两次的可交互图集链接)"
>
<i className="fa-solid fa-link text-[10px]" />
· · ·
</button>
) : null
}
aboveCanvasLeft={ aboveCanvasLeft={
<> <>
<button <button
+13
View File
@@ -178,6 +178,7 @@ export function PlayCanvas({
orientation = "landscape", orientation = "landscape",
aboveCanvas, aboveCanvas,
aboveCanvasLeft, aboveCanvasLeft,
belowCanvas,
dialogueHistory = [], dialogueHistory = [],
}: { }: {
imageUrl: string | null; imageUrl: string | null;
@@ -196,6 +197,8 @@ export function PlayCanvas({
aboveCanvas?: ReactNode; aboveCanvas?: ReactNode;
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。 // 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
aboveCanvasLeft?: ReactNode; aboveCanvasLeft?: ReactNode;
// 渲染在图片正下方、右对齐的 slot(画面外、紧贴右下角),与 aboveCanvas 垂直镜像。
belowCanvas?: ReactNode;
dialogueHistory?: DialogueHistoryItem[]; dialogueHistory?: DialogueHistoryItem[];
}) { }) {
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
@@ -401,6 +404,11 @@ export function PlayCanvas({
{aboveCanvasLeft} {aboveCanvasLeft}
</div> </div>
)} )}
{!fullViewport && belowCanvas && (
<div className="absolute top-full right-0 mt-2 flex items-center gap-2">
{belowCanvas}
</div>
)}
{beat && ( {beat && (
<div <div
@@ -589,6 +597,11 @@ export function PlayCanvas({
{aboveCanvasLeft} {aboveCanvasLeft}
</div> </div>
)} )}
{!fullViewport && belowCanvas && (
<div className="absolute top-full right-0 mt-2 flex items-center gap-2">
{belowCanvas}
</div>
)}
</div> </div>
)} )}
+1
View File
@@ -52,6 +52,7 @@ type AnalyticsEventData = {
tts_toggle: { muted: boolean }; tts_toggle: { muted: boolean };
fullscreen_toggle: { on: boolean }; fullscreen_toggle: { on: boolean };
play_heartbeat: never; play_heartbeat: never;
gallery_export: { scene_count: number };
}; };
export type AnalyticsEvent = keyof AnalyticsEventData; export type AnalyticsEvent = keyof AnalyticsEventData;
+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);
}