Visual Journal deploy
Deploy 5554830 to Docker Space
e4e0afe
Raw
History Blame Contribute Delete
7.33 kB
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<string, unknown>;
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<RetentionResult[]> {
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<string | undefined> {
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';
}