refactor(share): remove GALLERY_SECRET, use plaintext + SHA-256 integrity for .infiplot files

The encrypted .infiplot format (AES-256-GCM via GALLERY_SECRET) provided no
meaningful security — the payload is AI-generated story content with no
credentials or PII, and the project is open source. Replace with plaintext +
SHA-256 integrity check (format v2). Story share is now always enabled without
requiring a server secret.

- galleryCrypto.ts: AES-256-GCM → plaintext + SHA-256 hash; remove secret param
- 4 API routes: remove GALLERY_SECRET guard and 503 fallback
- story-unpack: forward specific error messages (v1 compat, hash mismatch)
- gallery/page.tsx: remove stale AES-GCM comment
- AGENTS.md: document gallery-pack/gallery-unpack routes
- .env.example, wrangler.jsonc: remove GALLERY_SECRET references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-18 21:41:56 +08:00
parent 03dccd7c74
commit 64cf9c330d
9 changed files with 48 additions and 143 deletions
+1 -15
View File
@@ -4,18 +4,7 @@ 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 };
@@ -34,10 +23,7 @@ export async function POST(req: Request): Promise<Response> {
);
}
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 bytes = await packDoc(docStr);
const ab = new ArrayBuffer(bytes.byteLength);
new Uint8Array(ab).set(bytes);
return new Response(ab, {
+1 -17
View File
@@ -2,25 +2,9 @@ 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. Bumped to fit pre-baked beat audio.
const MAX_FILE_BYTES = 13_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();
@@ -35,7 +19,7 @@ export async function POST(req: Request): Promise<Response> {
}
try {
const docStr = await unpackDoc(new Uint8Array(ab), secret);
const docStr = await unpackDoc(new Uint8Array(ab));
return Response.json({ docStr });
} catch (e) {
return Response.json(
+1 -9
View File
@@ -5,14 +5,6 @@ export const runtime = "nodejs";
const MAX_DOC_BYTES = 12_000_000;
export async function POST(req: Request): Promise<Response> {
const secret = process.env.GALLERY_SECRET;
if (!secret) {
return Response.json(
{ error: "剧情分享未启用 (GALLERY_SECRET 未配置)" },
{ status: 503 },
);
}
const contentLength = req.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) {
return Response.json(
@@ -39,7 +31,7 @@ export async function POST(req: Request): Promise<Response> {
);
}
const bytes = await packDoc(docStr, secret);
const bytes = await packDoc(docStr);
const ab = new ArrayBuffer(bytes.byteLength);
new Uint8Array(ab).set(bytes);
return new Response(ab, {
+3 -11
View File
@@ -5,14 +5,6 @@ export const runtime = "nodejs";
const MAX_FILE_BYTES = 13_000_000;
export async function POST(req: Request): Promise<Response> {
const secret = process.env.GALLERY_SECRET;
if (!secret) {
return Response.json(
{ error: "剧情分享未启用 (GALLERY_SECRET 未配置)" },
{ status: 503 },
);
}
const contentLength = req.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_FILE_BYTES) {
return Response.json({ error: "文件太大" }, { status: 413 });
@@ -32,11 +24,11 @@ export async function POST(req: Request): Promise<Response> {
}
try {
const docStr = await unpackDoc(new Uint8Array(ab), secret);
const docStr = await unpackDoc(new Uint8Array(ab));
return Response.json({ docStr });
} catch {
} catch (e) {
return Response.json(
{ error: "剧情文件解包失败" },
{ error: e instanceof Error ? e.message : "剧情文件解包失败" },
{ status: 400 },
);
}