import { appLogger } from '@/lib/app-logger'; import { isValidImageFilename } from '@/lib/image-request-utils'; import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; import { resolveImageOutputDir, verifyAccessToken, verifyPasswordHash } from '@/lib/server-runtime'; import { withWebuiImageFilenameLocks } from '@/lib/webui-image-retention-lock'; import { getWebuiImageRetentionStore, type WebuiImageRetentionAction } from '@/lib/webui-image-retention-store'; import { NextRequest, NextResponse } from 'next/server'; import fs from 'node:fs/promises'; import path from 'node:path'; export const MAX_RETENTION_BATCH_SIZE = 100; type RetentionRequestBody = { action: WebuiImageRetentionAction; filenames: string[]; passwordHash?: string; }; type RetentionResult = { filename: string; success: boolean; error?: string; }; export async function GET(request: NextRequest) { const authorizationError = verifyRetentionReader(request); if (authorizationError) return authorizationError; try { const store = await getWebuiImageRetentionStore(); return NextResponse.json({ filenames: await store.listPermanentFilenames() }); } catch (error) { appLogger.error('读取自动清理保护状态失败。', error); return NextResponse.json({ error: '读取自动清理保护状态失败。' }, { status: 500 }); } } export async function POST(request: NextRequest) { let body: RetentionRequestBody; try { body = readRetentionRequest(await request.json()); } catch (error) { const message = error instanceof Error ? error.message : '保留请求无效。'; return NextResponse.json({ error: message }, { status: 400 }); } const authorizationError = verifyRetentionWriter(body.passwordHash); if (authorizationError) return authorizationError; try { const results = await applyRetentionRequest(body); return NextResponse.json({ results }, { status: results.every((result) => result.success) ? 200 : 207 }); } catch (error) { appLogger.error('更新自动清理保护状态失败。', error); return NextResponse.json({ error: '更新自动清理保护状态失败。' }, { status: 500 }); } } function verifyRetentionReader(request: NextRequest): NextResponse | undefined { const appPassword = process.env.APP_PASSWORD?.trim(); if (!appPassword) return undefined; const accessToken = request.cookies.get('gptImageAccess')?.value; if (verifyAccessToken(accessToken, appPassword)) return undefined; return NextResponse.json( { error: '未授权:访问令牌无效。', code: accessToken ? PAGE_PASSWORD_AUTH_ERROR_CODES.invalid : PAGE_PASSWORD_AUTH_ERROR_CODES.missing }, { status: 401 } ); } function verifyRetentionWriter(passwordHash: string | undefined): NextResponse | undefined { const appPassword = process.env.APP_PASSWORD?.trim(); if (!appPassword) return undefined; if (!passwordHash) { return NextResponse.json({ error: '未授权:缺少访问码哈希。' }, { status: 401 }); } if (!verifyPasswordHash(passwordHash, appPassword)) { return NextResponse.json({ error: '未授权:访问码无效。' }, { status: 401 }); } return undefined; } function readRetentionRequest(value: unknown): RetentionRequestBody { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('保留请求无效:必须是 JSON 对象。'); } const body = value as Record; if (body.action !== 'preserve' && body.action !== 'release') { throw new Error('保留请求无效:action 必须是 preserve 或 release。'); } if (!Array.isArray(body.filenames) || body.filenames.some((filename) => typeof filename !== 'string')) { throw new Error('保留请求无效:filenames 必须是字符串数组。'); } if (body.filenames.length > MAX_RETENTION_BATCH_SIZE) { throw new Error(`保留请求无效:单次最多处理 ${MAX_RETENTION_BATCH_SIZE} 张图片。`); } if (body.passwordHash !== undefined && typeof body.passwordHash !== 'string') { throw new Error('保留请求无效:passwordHash 必须是字符串。'); } return { action: body.action, filenames: [...new Set(body.filenames)], ...(typeof body.passwordHash === 'string' ? { passwordHash: body.passwordHash } : {}) }; } async function applyRetentionRequest(body: RetentionRequestBody): Promise { const outputDir = resolveImageOutputDir(); if (body.action === 'preserve') { return await withWebuiImageFilenameLocks(body.filenames, async () => { const successfulFilenames: string[] = []; const results: RetentionResult[] = []; for (const filename of body.filenames) { const validationError = await validatePreservedFile(filename, outputDir); if (validationError) { results.push({ filename, success: false, error: validationError }); continue; } successfulFilenames.push(filename); results.push({ filename, success: true }); } if (successfulFilenames.length > 0) { await (await getWebuiImageRetentionStore()).preserve(successfulFilenames); } return results; }); } return await withWebuiImageFilenameLocks(body.filenames, async () => { const successfulFilenames: string[] = []; const results: RetentionResult[] = []; for (const filename of body.filenames) { const validationError = validateReleaseFilename(filename); if (validationError) { results.push({ filename, success: false, error: validationError }); continue; } successfulFilenames.push(filename); results.push({ filename, success: true }); } if (successfulFilenames.length > 0) { await (await getWebuiImageRetentionStore()).release(successfulFilenames); } return results; }); } async function validatePreservedFile(filename: string, outputDir: string): Promise { if (!isValidImageFilename(filename)) return '文件名格式无效。'; const filepath = path.resolve(outputDir, filename); if (path.dirname(filepath) !== outputDir) return '文件名格式无效。'; try { const stats = await fs.lstat(filepath); if (!stats.isFile() || stats.isSymbolicLink()) return '文件必须是常规文件。'; return undefined; } catch (error) { if (isMissingFileError(error)) return '文件不存在。'; appLogger.error('校验自动清理保护图片文件失败。', error); return '读取文件失败。'; } } function validateReleaseFilename(filename: string): string | undefined { return isValidImageFilename(filename) ? undefined : '文件名格式无效。'; } function isMissingFileError(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; }