refactor(web): remove client-side BYO API key feature

The BYO (Bring Your Own) API key configuration for LLM and image
generation will be re-implemented via Cloudflare Workers. Remove
the client-side implementation to prepare for that migration.

TTS (text-to-speech) BYO key support is intentionally preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-06 17:42:00 +08:00
parent 3625f935ed
commit d646ce8db8
11 changed files with 11 additions and 584 deletions
-23
View File
@@ -1,23 +0,0 @@
export const BYO_STORAGE_KEY = "infiplot:byoApi";
const MAX_HEADER_SIZE = 2048;
export function getByoHeaders(): Record<string, string> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(BYO_STORAGE_KEY);
if (raw && raw.length <= MAX_HEADER_SIZE) {
const parsed = JSON.parse(raw);
if (parsed.llm?.enabled || parsed.painter?.enabled) {
return { "x-byo-api": raw };
}
}
} catch {
/* ignore */
}
return {};
}
export function isByoActive(): boolean {
return Object.keys(getByoHeaders()).length > 0;
}
+2 -49
View File
@@ -3,7 +3,6 @@ import type {
ProviderProtocol,
TtsConfig,
} from "@infiplot/types";
import { isPublicUrl } from "./validateUrl";
const VALID_PROTOCOLS = [
"openai_compatible",
@@ -50,23 +49,8 @@ function loadTtsConfig(): TtsConfig | undefined {
return { baseUrl, apiKey, speechModel };
}
function safeEndpoint(v: unknown): string | undefined {
if (typeof v !== "string" || v.length === 0) return undefined;
const trimmed = v.trim();
if (!trimmed || !isPublicUrl(trimmed)) {
console.error(`BYO endpoint rejected (not a public HTTPS URL): ${v.slice(0, 100).replace(/[\r\n]/g, "")}`);
return undefined;
}
return trimmed;
}
function safeString(v: unknown, max: number): string | undefined {
if (typeof v !== "string" || v.length === 0) return undefined;
return v.slice(0, max).replace(/[\x00-\x1f]/g, "");
}
export function loadEngineConfig(headers?: Headers): EngineConfig {
const config: EngineConfig = {
export function loadEngineConfig(): EngineConfig {
return {
text: {
baseUrl: readVar("TEXT_BASE_URL"),
apiKey: readVar("TEXT_API_KEY"),
@@ -88,35 +72,4 @@ export function loadEngineConfig(headers?: Headers): EngineConfig {
tts: loadTtsConfig(),
mockImage: readOptionalVar("MOCK_IMAGE") === "true",
};
const byoHeader = headers?.get("x-byo-api");
if (byoHeader) {
if (byoHeader.length > 2048) {
console.error("x-byo-api header exceeds 2 KB limit, ignoring");
} else {
try {
const byo = JSON.parse(byoHeader);
if (byo.llm?.enabled) {
const ep = safeEndpoint(byo.llm?.endpoint);
const key = safeString(byo.llm?.apiKey, 256);
const model = safeString(byo.llm?.model, 128);
if (ep) { config.text.baseUrl = ep; config.vision.baseUrl = ep; }
if (key) { config.text.apiKey = key; config.vision.apiKey = key; }
if (model) { config.text.model = model; config.vision.model = model; }
}
if (byo.painter?.enabled) {
const ep = safeEndpoint(byo.painter?.endpoint);
const key = safeString(byo.painter?.apiKey, 256);
const model = safeString(byo.painter?.model, 128);
if (ep) config.image.baseUrl = ep;
if (key) config.image.apiKey = key;
if (model) config.image.model = model;
}
} catch (e) {
console.error("Failed to parse x-byo-api header:", e);
}
}
}
return config;
}
-95
View File
@@ -1,95 +0,0 @@
// Block SSRF: only allow HTTPS URLs pointing to public internet hosts.
const ALLOWED_HOSTS = new Set([
"api.openai.com",
"api.anthropic.com",
"generativelanguage.googleapis.com",
"api.runware.ai",
"api.replicate.com",
"api.deepseek.com",
"dashscope.aliyuncs.com",
"api.siliconflow.cn",
"api.together.xyz",
"openrouter.ai",
"api.mistral.ai",
"api.groq.com",
"api.fireworks.ai",
"api.cohere.com",
]);
const BLOCKED_HOSTS = new Set([
"localhost",
"metadata.google.internal",
"metadata.internal",
]);
const PRIVATE_RANGES = [
{ start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) },
{ start: ip4ToNum(10, 0, 0, 0), end: ip4ToNum(10, 255, 255, 255) },
{ start: ip4ToNum(100, 64, 0, 0), end: ip4ToNum(100, 127, 255, 255) },
{ start: ip4ToNum(127, 0, 0, 0), end: ip4ToNum(127, 255, 255, 255) },
{ start: ip4ToNum(169, 254, 0, 0), end: ip4ToNum(169, 254, 255, 255) },
{ start: ip4ToNum(172, 16, 0, 0), end: ip4ToNum(172, 31, 255, 255) },
{ start: ip4ToNum(192, 168, 0, 0), end: ip4ToNum(192, 168, 255, 255) },
{ start: ip4ToNum(224, 0, 0, 0), end: ip4ToNum(239, 255, 255, 255) },
{ start: ip4ToNum(240, 0, 0, 0), end: ip4ToNum(255, 255, 255, 255) },
];
function ip4ToNum(a: number, b: number, c: number, d: number): number {
return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
}
function parseIp4(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) return null;
for (const p of parts) {
const n = Number(p);
if (!Number.isInteger(n) || n < 0 || n > 255) return null;
}
return ip4ToNum(
Number(parts[0]),
Number(parts[1]),
Number(parts[2]),
Number(parts[3]),
);
}
function isPrivateIp(ip: string): boolean {
const n = parseIp4(ip);
if (n === null) return true;
return PRIVATE_RANGES.some((r) => n >= r.start && n <= r.end);
}
export function isAllowlistedHost(hostname: string): boolean {
return ALLOWED_HOSTS.has(hostname);
}
export function isPublicUrl(raw: string): boolean {
let url: URL;
try {
url = new URL(raw);
} catch {
return false;
}
if (url.protocol !== "https:") return false;
if (url.username || url.password) return false;
const host = url.hostname.toLowerCase();
if (BLOCKED_HOSTS.has(host)) return false;
// Reject all IPv6 addresses (including ::ffff:127.0.0.1 mapped forms)
if (host.includes(":")) return false;
// Fast path: known API providers always pass
if (ALLOWED_HOSTS.has(host)) return true;
// For unknown domains, block IP literals pointing to private ranges
const ipv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
if (ipv4) return !isPrivateIp(host);
// Domain names: allow — DNS rebinding is mitigated by redirect: "manual"
// on fetchWithRetry and the fact that Vercel's runtime resolves DNS once
// per fetch (no keep-alive reuse across requests).
return true;
}