import type OpenAI from 'openai'; export const VALID_IMAGE_FILENAME_PATTERN = /^\d{13}(?:-[a-f0-9]{16})?-\d+\.(png|jpe?g|webp)$/i; export const MAX_IMAGE_COUNT = 10; export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; export const MAX_PROMPT_LENGTH = 32000; const VALID_MODE_VALUES = ['generate', 'edit'] as const; const VALID_MODEL_VALUES = ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2'] as const; const VALID_OUTPUT_FORMAT_VALUES = ['png', 'jpeg', 'webp'] as const; const VALID_GENERATE_QUALITY_VALUES = ['low', 'medium', 'high', 'auto'] as const; const VALID_EDIT_QUALITY_VALUES = ['low', 'medium', 'high', 'auto'] as const; const VALID_BACKGROUND_VALUES = ['transparent', 'opaque', 'auto'] as const; const VALID_MODERATION_VALUES = ['low', 'auto'] as const; const VALID_LEGACY_SIZE_VALUES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const; export type ImageMode = (typeof VALID_MODE_VALUES)[number]; export type GptImageModel = (typeof VALID_MODEL_VALUES)[number]; export type ValidOutputFormat = (typeof VALID_OUTPUT_FORMAT_VALUES)[number]; export type GenerateQuality = (typeof VALID_GENERATE_QUALITY_VALUES)[number]; export type EditQuality = (typeof VALID_EDIT_QUALITY_VALUES)[number]; export type Background = (typeof VALID_BACKGROUND_VALUES)[number]; export type Moderation = (typeof VALID_MODERATION_VALUES)[number]; export type StorageMode = 'fs' | 'indexeddb'; export class RequestValidationError extends Error { readonly status: number; constructor(message: string, status = 400) { super(message); this.name = 'RequestValidationError'; this.status = status; } } function isOneOf(value: string, allowed: T): value is T[number] { return (allowed as readonly string[]).includes(value); } export function readRequiredText(formData: FormData, field: string, maxLength = MAX_PROMPT_LENGTH): string { const value = formData.get(field); if (typeof value !== 'string' || value.trim().length === 0) { throw new RequestValidationError(`缺少必填参数:${field}`); } if (value.length > maxLength) { throw new RequestValidationError(`${field} 超过 ${maxLength} 个字符的最大长度。`); } return value; } export function readMode(formData: FormData): ImageMode { const value = formData.get('mode'); if (typeof value !== 'string' || !isOneOf(value, VALID_MODE_VALUES)) { throw new RequestValidationError('mode 无效,必须是 generate 或 edit。'); } return value; } export function readModel(formData: FormData): GptImageModel { const value = formData.get('model'); if (value === null) return 'gpt-image-2'; if (typeof value !== 'string' || !isOneOf(value, VALID_MODEL_VALUES)) { throw new RequestValidationError('model 无效。'); } return value; } export function readCount(formData: FormData, field: string, fallback: number, min: number, max: number): number { const rawValue = formData.get(field); if (rawValue === null) return fallback; if (typeof rawValue !== 'string' || !/^\d+$/.test(rawValue)) { throw new RequestValidationError(`${field} 必须是整数。`); } const value = Number(rawValue); if (!Number.isInteger(value) || value < min || value > max) { throw new RequestValidationError(`${field} 必须在 ${min} 到 ${max} 之间。`); } return value; } export function readOutputFormat(formData: FormData): ValidOutputFormat { const rawValue = formData.get('output_format'); if (rawValue === null) return 'png'; if (typeof rawValue !== 'string') { throw new RequestValidationError('output_format 必须是字符串。'); } const normalized = rawValue.toLowerCase(); const mapped = normalized === 'jpg' ? 'jpeg' : normalized; if (!isOneOf(mapped, VALID_OUTPUT_FORMAT_VALUES)) { throw new RequestValidationError('output_format 无效,必须是 png、jpeg 或 webp。'); } return mapped; } export function readGenerateQuality(formData: FormData): GenerateQuality { const value = formData.get('quality'); if (value === null) return 'high'; if (typeof value !== 'string' || !isOneOf(value, VALID_GENERATE_QUALITY_VALUES)) { throw new RequestValidationError('quality 无效。'); } return value; } export function readEditQuality(formData: FormData): EditQuality { const value = formData.get('quality'); if (value === null) return 'auto'; if (typeof value !== 'string' || !isOneOf(value, VALID_EDIT_QUALITY_VALUES)) { throw new RequestValidationError('quality 无效。'); } return value; } export function readBackground(formData: FormData, model: GptImageModel): Background { const value = formData.get('background'); if (value === null) return 'auto'; if (typeof value !== 'string' || !isOneOf(value, VALID_BACKGROUND_VALUES)) { throw new RequestValidationError('background 无效。'); } if (model === 'gpt-image-2' && value === 'transparent') { throw new RequestValidationError('gpt-image-2 不支持 transparent 背景。'); } return value; } export function readModeration(formData: FormData): Moderation { const value = formData.get('moderation'); if (value === null) return 'auto'; if (typeof value !== 'string' || !isOneOf(value, VALID_MODERATION_VALUES)) { throw new RequestValidationError('moderation 无效。'); } return value; } export function readSize(formData: FormData, field: string, fallback: string, model: GptImageModel): string { const value = formData.get(field); if (value === null) return fallback; if (typeof value !== 'string' || value.trim().length === 0) { throw new RequestValidationError(`${field} 必须是字符串。`); } if (model !== 'gpt-image-2' && !isOneOf(value, VALID_LEGACY_SIZE_VALUES)) { throw new RequestValidationError(`${field} 对 ${model} 无效。`); } if (model === 'gpt-image-2' && value !== 'auto' && !/^\d+x\d+$/.test(value)) { throw new RequestValidationError(`${field} 必须是 auto 或 WxH 格式的尺寸值。`); } return value; } export function readOutputCompression(formData: FormData, outputFormat: ValidOutputFormat): number | undefined { const value = formData.get('output_compression'); if (value === null) return undefined; if (outputFormat === 'png') { throw new RequestValidationError('output_compression 仅适用于 jpeg 或 webp 输出。'); } if (typeof value !== 'string' || !/^\d+$/.test(value)) { throw new RequestValidationError('output_compression 必须是整数。'); } const compression = Number(value); if (!Number.isInteger(compression) || compression < 0 || compression > 100) { throw new RequestValidationError('output_compression 必须在 0 到 100 之间。'); } return compression; } export function readStorageMode(env: NodeJS.ProcessEnv): StorageMode { const explicitMode = env.NEXT_PUBLIC_IMAGE_STORAGE_MODE; if (explicitMode === 'fs' || explicitMode === 'indexeddb') return explicitMode; if (explicitMode) { throw new RequestValidationError('NEXT_PUBLIC_IMAGE_STORAGE_MODE 必须是 fs 或 indexeddb。', 500); } return env.VERCEL === '1' ? 'indexeddb' : 'fs'; } export function assertSafeApiOverride(requestApiKey: string, requestApiBaseUrl: string): void { if (requestApiBaseUrl && !requestApiKey) { throw new RequestValidationError('填写自定义 API URL 时必须同时填写 API Key,避免服务器密钥被发送到未知地址。'); } } export function validateApiBaseUrl(baseUrl: string): void { if (!baseUrl) return; let parsed: URL; try { parsed = new URL(baseUrl); } catch { throw new RequestValidationError('API URL 格式无效。'); } if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') { throw new RequestValidationError('API URL 必须使用 https,localhost 调试地址除外。'); } } export function isValidImageFilename(filename: string): boolean { return VALID_IMAGE_FILENAME_PATTERN.test(filename); } export function safeImagePath(baseDir: string, filename: string): string { if (!isValidImageFilename(filename)) { throw new RequestValidationError('文件名无效'); } return `${baseDir}/${filename}`; } export function readImageFiles(formData: FormData): File[] { const imageFiles: File[] = []; for (const [key, value] of formData.entries()) { if (key.startsWith('image_') && value instanceof File) { imageFiles.push(value); } } if (imageFiles.length === 0) { throw new RequestValidationError('编辑时必须提供图片文件。'); } if (imageFiles.length > MAX_IMAGE_COUNT) { throw new RequestValidationError(`一次最多只能编辑 ${MAX_IMAGE_COUNT} 张图片。`); } imageFiles.forEach((file) => validateUploadFile(file)); return imageFiles; } export function readMaskFile(formData: FormData): File | undefined { const value = formData.get('mask'); if (value === null) return undefined; if (!(value instanceof File)) { throw new RequestValidationError('mask 必须是 PNG 文件。'); } validateUploadFile(value, { requirePng: true, fieldName: 'mask' }); return value; } function validateUploadFile(file: File, options: { requirePng?: boolean; fieldName?: string } = {}): void { const fieldName = options.fieldName || file.name || 'image'; if (file.size <= 0) { throw new RequestValidationError(`${fieldName} 为空。`); } if (file.size > MAX_UPLOAD_BYTES) { throw new RequestValidationError(`${fieldName} 超过 ${MAX_UPLOAD_BYTES / 1024 / 1024} MB 限制。`); } if (!file.type.startsWith('image/')) { throw new RequestValidationError(`${fieldName} 必须是图片文件。`); } if (options.requirePng && file.type !== 'image/png') { throw new RequestValidationError(`${fieldName} 必须是 PNG 文件。`); } } export function createImageResult( filename: string, b64Json: string, outputFormat: ValidOutputFormat, storageMode: StorageMode ): { filename: string; b64_json: string; path?: string; output_format: string } { return { filename, b64_json: b64Json, output_format: outputFormat, ...(storageMode === 'fs' ? { path: `/api/image/${filename}` } : {}) }; } export type GenerateParams = Omit & { output_compression?: number; };