File size: 10,736 Bytes
c7d34c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
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<T extends readonly string[]>(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<OpenAI.Images.ImageGenerateParams, 'output_compression'> & {
    output_compression?: number;
};