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:
+4
-10
@@ -155,16 +155,10 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
|||||||
# 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) ----
|
# ---- 7. Gallery share files ----
|
||||||
# Server-side secret used to AES-256-GCM encrypt a played session into a
|
# Story share (`.infiplot` files) is always enabled — no secret needed.
|
||||||
# binary `.infiplot` share file the player can send to a friend. Friends drop
|
# Files use SHA-256 integrity checks instead of encryption because the
|
||||||
# the file into /gallery; the server decrypts and renders the same interactive
|
# payload is AI-generated story content, not sensitive data.
|
||||||
# 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=
|
|
||||||
|
|
||||||
# ---- 8. Auth · Supabase (optional — leave blank to disable) -------
|
# ---- 8. Auth · Supabase (optional — leave blank to disable) -------
|
||||||
# Sign up at https://supabase.com, create a project, copy the URL and
|
# Sign up at https://supabase.com, create a project, copy the URL and
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ Common routes live under `app/api/`:
|
|||||||
- `POST /api/beat-audio`: lazy TTS for a displayed beat; returns binary audio, or `204` when silent. `voice` is now OPTIONAL — when the server runs StepFun, the client omits the ~220KB Xiaomi reference audio and sends `stepfunVoiceId` / `voiceDescription` instead (saves Fast Origin Transfer bandwidth). The engine re-provisions on a provider mismatch before synthesizing.
|
- `POST /api/beat-audio`: lazy TTS for a displayed beat; returns binary audio, or `204` when silent. `voice` is now OPTIONAL — when the server runs StepFun, the client omits the ~220KB Xiaomi reference audio and sends `stepfunVoiceId` / `voiceDescription` instead (saves Fast Origin Transfer bandwidth). The engine re-provisions on a provider mismatch before synthesizing.
|
||||||
- `POST /api/parse-style-image`: extracts a style prompt from uploaded reference art.
|
- `POST /api/parse-style-image`: extracts a style prompt from uploaded reference art.
|
||||||
- `GET /api/tts-provider`: returns `{ provider: "stepfun" | "xiaomi" | null }` (the server's TTS provider, inferred from `TTS_BASE_URL`). Probed once at `/play` mount (non-BYO) so `fetchBeatAudio` can shape its request body — skip the ~220KB Xiaomi reference audio when the server runs StepFun. BYO client TTS takes precedence over this signal.
|
- `GET /api/tts-provider`: returns `{ provider: "stepfun" | "xiaomi" | null }` (the server's TTS provider, inferred from `TTS_BASE_URL`). Probed once at `/play` mount (non-BYO) so `fetchBeatAudio` can shape its request body — skip the ~220KB Xiaomi reference audio when the server runs StepFun. BYO client TTS takes precedence over this signal.
|
||||||
- `POST /api/story-pack` / `POST /api/story-unpack`: stateless AES-GCM packing/unpacking for playable story share `.infiplot` files; uses `GALLERY_SECRET`.
|
- `POST /api/story-pack` / `POST /api/story-unpack`: stateless packing/unpacking for playable story share `.infiplot` files (plaintext + SHA-256 integrity check, no encryption).
|
||||||
|
- `POST /api/gallery-pack` / `POST /api/gallery-unpack`: same format as story-pack/unpack but for gallery share files (5 MB pack limit vs story's 12 MB).
|
||||||
|
|
||||||
When changing public types or route payloads, update all route callers and client consumers in the same change.
|
When changing public types or route payloads, update all route callers and client consumers in the same change.
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ Use `.env.example` as the source of truth. Never commit `.env.local`, API keys,
|
|||||||
- `MOCK_IMAGE=true` skips image generation and returns a placeholder for cheap local iteration.
|
- `MOCK_IMAGE=true` skips image generation and returns a placeholder for cheap local iteration.
|
||||||
- `NEXT_PUBLIC_IMAGE_PROXY_URL` and `NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS` opt into browser-side image proxying for allowed hosts.
|
- `NEXT_PUBLIC_IMAGE_PROXY_URL` and `NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS` opt into browser-side image proxying for allowed hosts.
|
||||||
- Analytics uses optional Umami `NEXT_PUBLIC_UMAMI_*` values and must stay content-free/privacy-preserving.
|
- Analytics uses optional Umami `NEXT_PUBLIC_UMAMI_*` values and must stay content-free/privacy-preserving.
|
||||||
- `GALLERY_SECRET` enables encrypted `.infiplot` share files for gallery and playable story export/import.
|
- `.infiplot` share files use plaintext + SHA-256 integrity (no encryption, no secret needed); the feature is always enabled.
|
||||||
- `NEXT_PUBLIC_*` values are inlined at build time.
|
- `NEXT_PUBLIC_*` values are inlined at build time.
|
||||||
|
|
||||||
## File Dependency Map
|
## File Dependency Map
|
||||||
|
|||||||
@@ -4,18 +4,7 @@ export const runtime = "nodejs";
|
|||||||
|
|
||||||
const MAX_DOC_BYTES = 5_000_000;
|
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> {
|
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;
|
let docStr: string;
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as { docStr?: unknown };
|
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);
|
const bytes = await packDoc(docStr);
|
||||||
// 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);
|
const ab = new ArrayBuffer(bytes.byteLength);
|
||||||
new Uint8Array(ab).set(bytes);
|
new Uint8Array(ab).set(bytes);
|
||||||
return new Response(ab, {
|
return new Response(ab, {
|
||||||
|
|||||||
@@ -2,25 +2,9 @@ import { unpackDoc } from "@/lib/galleryCrypto";
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
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;
|
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> {
|
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;
|
let ab: ArrayBuffer;
|
||||||
try {
|
try {
|
||||||
ab = await req.arrayBuffer();
|
ab = await req.arrayBuffer();
|
||||||
@@ -35,7 +19,7 @@ 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));
|
||||||
return Response.json({ docStr });
|
return Response.json({ docStr });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ export const runtime = "nodejs";
|
|||||||
const MAX_DOC_BYTES = 12_000_000;
|
const MAX_DOC_BYTES = 12_000_000;
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
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");
|
const contentLength = req.headers.get("content-length");
|
||||||
if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) {
|
if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) {
|
||||||
return Response.json(
|
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);
|
const ab = new ArrayBuffer(bytes.byteLength);
|
||||||
new Uint8Array(ab).set(bytes);
|
new Uint8Array(ab).set(bytes);
|
||||||
return new Response(ab, {
|
return new Response(ab, {
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ export const runtime = "nodejs";
|
|||||||
const MAX_FILE_BYTES = 13_000_000;
|
const MAX_FILE_BYTES = 13_000_000;
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
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");
|
const contentLength = req.headers.get("content-length");
|
||||||
if (contentLength && Number(contentLength) > MAX_FILE_BYTES) {
|
if (contentLength && Number(contentLength) > MAX_FILE_BYTES) {
|
||||||
return Response.json({ error: "文件太大" }, { status: 413 });
|
return Response.json({ error: "文件太大" }, { status: 413 });
|
||||||
@@ -32,11 +24,11 @@ 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));
|
||||||
return Response.json({ docStr });
|
return Response.json({ docStr });
|
||||||
} catch {
|
} catch (e) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "剧情文件解包失败" },
|
{ error: e instanceof Error ? e.message : "剧情文件解包失败" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -954,11 +954,6 @@ function GalleryInner() {
|
|||||||
}, [doc, downloadingScenes]);
|
}, [doc, downloadingScenes]);
|
||||||
|
|
||||||
// ── Import a friend-shared `.infiplot` file ──────────────────────────
|
// ── Import a friend-shared `.infiplot` file ──────────────────────────
|
||||||
// The file is AES-GCM ciphertext only this deployment can decrypt; we POST
|
|
||||||
// the raw bytes to /api/gallery-unpack and let the server hand us back the
|
|
||||||
// doc as a JSON string. GCM's auth tag means a tampered or wrong-key file
|
|
||||||
// surfaces as a 400 with a human-readable error here — no need to verify
|
|
||||||
// anything client-side.
|
|
||||||
const loadDocFromFile = useCallback(async (file: File) => {
|
const loadDocFromFile = useCallback(async (file: File) => {
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
|
|||||||
+33
-72
@@ -1,98 +1,59 @@
|
|||||||
// Gallery share-file crypto. AES-256-GCM via Web Crypto — same API in Node 22+
|
// Gallery share-file packing. Plaintext + SHA-256 integrity check.
|
||||||
// (`globalThis.crypto`) and Cloudflare Workers, so the `runtime = "nodejs"`
|
// Uses only Web Crypto (`globalThis.crypto`) so it works in both
|
||||||
// routes still port cleanly to the OpenNext / Cloudflare build later.
|
// Node 22+ and Cloudflare Workers.
|
||||||
//
|
//
|
||||||
// Threat model:
|
// File layout (raw bytes):
|
||||||
// - 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
|
// 0..3 "IFPL" magic — lets us refuse anything that's not ours
|
||||||
// 4 version (=1) bumped if the format ever changes
|
// 4 version (=2) v1 was AES-256-GCM encrypted (removed)
|
||||||
// 5..16 nonce (12 B) random per file; GCM requires non-repeating nonces
|
// 5..36 SHA-256 (32 B) integrity hash of the plaintext
|
||||||
// per key (12-B random gives ~2^-32 collision risk at
|
// 37.. plaintext raw UTF-8 JSON
|
||||||
// ~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 MAGIC = [0x49, 0x46, 0x50, 0x4c] as const; // "IFPL"
|
||||||
const VERSION = 1;
|
const VERSION = 2;
|
||||||
const NONCE_LEN = 12;
|
const HASH_LEN = 32;
|
||||||
const HEADER_LEN = MAGIC.length + 1 + NONCE_LEN;
|
const HEADER_LEN = MAGIC.length + 1 + HASH_LEN; // 37
|
||||||
|
|
||||||
async function deriveKey(secret: string): Promise<CryptoKey> {
|
export async function packDoc(docStr: string): Promise<Uint8Array> {
|
||||||
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 plaintext = new TextEncoder().encode(docStr);
|
||||||
const ciphertext = new Uint8Array(
|
const hash = new Uint8Array(
|
||||||
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, plaintext),
|
await crypto.subtle.digest("SHA-256", plaintext),
|
||||||
);
|
);
|
||||||
|
|
||||||
const out = new Uint8Array(HEADER_LEN + ciphertext.length);
|
const out = new Uint8Array(HEADER_LEN + plaintext.length);
|
||||||
out.set(MAGIC, 0);
|
out.set(MAGIC, 0);
|
||||||
out[MAGIC.length] = VERSION;
|
out[MAGIC.length] = VERSION;
|
||||||
out.set(nonce, MAGIC.length + 1);
|
out.set(hash, MAGIC.length + 1);
|
||||||
out.set(ciphertext, HEADER_LEN);
|
out.set(plaintext, HEADER_LEN);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unpackDoc(
|
export async function unpackDoc(blob: Uint8Array): Promise<string> {
|
||||||
blob: Uint8Array,
|
if (blob.length < HEADER_LEN) {
|
||||||
secret: string,
|
throw new Error("文件太小,不是合法的分享文件");
|
||||||
): 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++) {
|
for (let i = 0; i < MAGIC.length; i++) {
|
||||||
if (blob[i] !== MAGIC[i]) {
|
if (blob[i] !== MAGIC[i]) {
|
||||||
throw new Error("文件格式不对,不是合法的图集分享文件");
|
throw new Error("文件格式不对,不是合法的分享文件");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const version = blob[MAGIC.length];
|
const version = blob[MAGIC.length];
|
||||||
|
if (version === 1) {
|
||||||
|
throw new Error("此文件由旧版本加密导出,当前版本不再支持加密格式");
|
||||||
|
}
|
||||||
if (version !== VERSION) {
|
if (version !== VERSION) {
|
||||||
throw new Error(`图集分享文件版本不被支持: v${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);
|
const storedHash = blob.slice(MAGIC.length + 1, HEADER_LEN);
|
||||||
let plaintext: ArrayBuffer;
|
const plaintext = blob.slice(HEADER_LEN);
|
||||||
try {
|
const computedHash = new Uint8Array(
|
||||||
plaintext = await crypto.subtle.decrypt(
|
await crypto.subtle.digest("SHA-256", plaintext),
|
||||||
{ name: "AES-GCM", iv: nonce },
|
|
||||||
key,
|
|
||||||
ciphertext,
|
|
||||||
);
|
);
|
||||||
} catch {
|
|
||||||
// GCM auth tag failure → decryption refuses. Maps tamper + wrong-key both
|
if (storedHash.length !== computedHash.length ||
|
||||||
// here, which is the right behavior: we can't distinguish, and neither
|
!storedHash.every((b, i) => b === computedHash[i])) {
|
||||||
// should leak more than "this file isn't for this server".
|
throw new Error("文件校验失败:内容可能被改动过");
|
||||||
throw new Error("文件校验失败:可能被改动过,或来自另一台部署");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TextDecoder().decode(plaintext);
|
return new TextDecoder().decode(plaintext);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
// ── Secrets (set via Dashboard or `wrangler secret put`) ─────────────
|
// ── Secrets (set via Dashboard or `wrangler secret put`) ─────────────
|
||||||
// Required (3): TEXT_API_KEY, IMAGE_API_KEY, VISION_API_KEY
|
// Required (3): TEXT_API_KEY, IMAGE_API_KEY, VISION_API_KEY
|
||||||
// Optional (2): TTS_API_KEY (voice synthesis), GALLERY_SECRET (story share encryption)
|
// Optional (1): TTS_API_KEY (voice synthesis)
|
||||||
//
|
//
|
||||||
// ── Runtime variables (set via Dashboard) ────────────────────────────
|
// ── Runtime variables (set via Dashboard) ────────────────────────────
|
||||||
// Required (6): TEXT_BASE_URL, TEXT_MODEL, IMAGE_BASE_URL, IMAGE_MODEL,
|
// Required (6): TEXT_BASE_URL, TEXT_MODEL, IMAGE_BASE_URL, IMAGE_MODEL,
|
||||||
|
|||||||
Reference in New Issue
Block a user