chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

346 lines
11 KiB
TypeScript

// 系统设置 API
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import type { Prisma } from '@prisma/client';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { isSystemAdmin } from '@/lib/department-permission';
import { proxyHttpUrl } from '@/lib/url-rewrite';
import {
DEFAULT_HEADER_LOGO_URL,
DEFAULT_LOGIN_LOGO_URL,
DEFAULT_SITE_NAME,
normalizeBrandLogoUrl,
} from '@/lib/brand-assets';
const DEFAULT_HEADER_BG_COLOR = '#1f2937';
const NO_STORE_HEADERS = {
'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
};
const systemSettingsSelect = {
id: true,
headerBgColor: true,
siteName: true,
headerLogoUrl: true,
loginLogoUrl: true,
s3Endpoint: true,
s3Bucket: true,
s3Region: true,
s3PublicUrl: true,
maintenanceEnabled: true,
maintenanceMessage: true,
maintenanceStartTime: true,
maintenanceEndTime: true,
} as const;
type SystemSettingsRecord = Prisma.SystemSettingsGetPayload<{
select: typeof systemSettingsSelect;
}>;
type SettingsSessionUser = {
id?: string | null;
role?: string | null;
isSuperAdmin?: boolean | null;
};
function jsonNoStore(body: unknown, status = 200) {
return NextResponse.json(body, {
status,
headers: NO_STORE_HEADERS,
});
}
function canManageSystemSettings(user: SettingsSessionUser | null | undefined) {
return Boolean(
user?.id &&
(isSystemAdmin(user.role) || user.isSuperAdmin === true),
);
}
function toPublicSettingsDto(settings: SystemSettingsRecord | null) {
const headerLogoUrl =
normalizeBrandLogoUrl(settings?.headerLogoUrl) || DEFAULT_HEADER_LOGO_URL;
const loginLogoUrl =
normalizeBrandLogoUrl(settings?.loginLogoUrl) || DEFAULT_LOGIN_LOGO_URL;
return {
headerBgColor: settings?.headerBgColor || DEFAULT_HEADER_BG_COLOR,
siteName: settings?.siteName || DEFAULT_SITE_NAME,
headerLogoUrl: proxyHttpUrl(headerLogoUrl) || headerLogoUrl,
loginLogoUrl: proxyHttpUrl(loginLogoUrl) || loginLogoUrl,
maintenanceEnabled: settings?.maintenanceEnabled ?? false,
maintenanceMessage: settings?.maintenanceMessage ?? null,
maintenanceStartTime: settings?.maintenanceStartTime ?? null,
maintenanceEndTime: settings?.maintenanceEndTime ?? null,
};
}
function toAdminSettingsDto(
settings: SystemSettingsRecord | null,
s3CredentialsConfigured: boolean,
) {
const normalizedStoredHeaderLogoUrl = normalizeBrandLogoUrl(settings?.headerLogoUrl);
const normalizedStoredLoginLogoUrl = normalizeBrandLogoUrl(settings?.loginLogoUrl);
const headerLogoUrl = normalizedStoredHeaderLogoUrl || DEFAULT_HEADER_LOGO_URL;
const loginLogoUrl = normalizedStoredLoginLogoUrl || DEFAULT_LOGIN_LOGO_URL;
return {
headerBgColor: settings?.headerBgColor || DEFAULT_HEADER_BG_COLOR,
siteName: settings?.siteName || DEFAULT_SITE_NAME,
// 管理页编辑的是数据库中的长期地址,预览地址只用于 <img> 展示。
headerLogoUrl,
loginLogoUrl,
headerLogoPreviewUrl: proxyHttpUrl(headerLogoUrl) || headerLogoUrl,
loginLogoPreviewUrl: proxyHttpUrl(loginLogoUrl) || loginLogoUrl,
maintenanceEnabled: settings?.maintenanceEnabled ?? false,
maintenanceMessage: settings?.maintenanceMessage ?? null,
maintenanceStartTime: settings?.maintenanceStartTime ?? null,
maintenanceEndTime: settings?.maintenanceEndTime ?? null,
s3Endpoint: settings?.s3Endpoint || '',
s3Bucket: settings?.s3Bucket || '',
s3Region: settings?.s3Region || 'us-east-1',
s3PublicUrl: settings?.s3PublicUrl || '',
s3CredentialsConfigured,
brandLogoConfigurationNeedsRepair: Boolean(
settings &&
(!normalizedStoredHeaderLogoUrl || !normalizedStoredLoginLogoUrl),
),
};
}
async function hasStoredS3Credentials(settingsId: string | null | undefined) {
if (!settingsId) {
return false;
}
// 只让数据库判断是否已配置,不把 Secret 原文读入应用进程。
const configuredCount = await prisma.systemSettings.count({
where: {
id: settingsId,
s3AccessKey: { not: '' },
s3SecretKey: { not: '' },
},
});
return configuredCount > 0;
}
// GET - 公开读取仅返回展示与维护信息;系统管理员可额外读取非敏感 S3 配置。
export async function GET() {
try {
const [session, settings] = await Promise.all([
getServerSession(authOptions),
prisma.systemSettings.findFirst({ select: systemSettingsSelect }),
]);
const user = session?.user as SettingsSessionUser | undefined;
const response = canManageSystemSettings(user)
? toAdminSettingsDto(
settings,
await hasStoredS3Credentials(settings?.id),
)
: toPublicSettingsDto(settings);
return jsonNoStore(response);
} catch (error) {
console.error('Get system settings error:', error);
return jsonNoStore({ error: '获取系统设置失败' }, 500);
}
}
// PUT - 更新系统设置(仅系统管理员或超级管理员)。
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
const user = session?.user as SettingsSessionUser | undefined;
if (!user?.id) {
return jsonNoStore({ error: '未登录' }, 401);
}
if (!canManageSystemSettings(user)) {
return jsonNoStore({ error: '无权限' }, 403);
}
const {
headerBgColor,
siteName,
headerLogoUrl,
loginLogoUrl,
s3Endpoint,
s3AccessKey,
s3SecretKey,
s3Bucket,
s3Region,
s3PublicUrl,
maintenanceEnabled,
maintenanceMessage,
maintenanceStartTime,
maintenanceEndTime,
} = await request.json();
const nextS3AccessKey =
typeof s3AccessKey === 'string' ? s3AccessKey.trim() : '';
const nextS3SecretKey =
typeof s3SecretKey === 'string' ? s3SecretKey.trim() : '';
const isCredentialRotationRequested = Boolean(
nextS3AccessKey || nextS3SecretKey,
);
if (
isCredentialRotationRequested &&
(!nextS3AccessKey || !nextS3SecretKey)
) {
return jsonNoStore(
{ error: 'Access Key 和 Secret Key 必须同时填写' },
400,
);
}
const normalizedHeaderLogoUrl =
headerLogoUrl === undefined
? undefined
: normalizeBrandLogoUrl(headerLogoUrl);
const normalizedLoginLogoUrl =
loginLogoUrl === undefined
? undefined
: normalizeBrandLogoUrl(loginLogoUrl);
if (headerLogoUrl !== undefined && !normalizedHeaderLogoUrl) {
return jsonNoStore(
{ error: '导航栏 LOGO 地址无效,请使用内置品牌资源或当前素材存储桶地址' },
400,
);
}
if (loginLogoUrl !== undefined && !normalizedLoginLogoUrl) {
return jsonNoStore(
{ error: '登录页 LOGO 地址无效,请使用内置品牌资源或当前素材存储桶地址' },
400,
);
}
const updateData: Prisma.SystemSettingsUpdateInput = {};
if (typeof headerBgColor === 'string') {
updateData.headerBgColor = headerBgColor;
}
if (typeof siteName === 'string') {
updateData.siteName = siteName;
}
if (normalizedHeaderLogoUrl) {
updateData.headerLogoUrl = normalizedHeaderLogoUrl;
}
if (normalizedLoginLogoUrl) {
updateData.loginLogoUrl = normalizedLoginLogoUrl;
}
if (typeof s3Endpoint === 'string') {
updateData.s3Endpoint = s3Endpoint;
}
if (isCredentialRotationRequested) {
updateData.s3AccessKey = nextS3AccessKey;
updateData.s3SecretKey = nextS3SecretKey;
}
if (typeof s3Bucket === 'string') {
updateData.s3Bucket = s3Bucket;
}
if (typeof s3Region === 'string') {
updateData.s3Region = s3Region;
}
if (typeof s3PublicUrl === 'string') {
updateData.s3PublicUrl = s3PublicUrl;
}
if (typeof maintenanceEnabled === 'boolean') {
updateData.maintenanceEnabled = maintenanceEnabled;
}
if (
typeof maintenanceMessage === 'string' ||
maintenanceMessage === null
) {
updateData.maintenanceMessage = maintenanceMessage;
}
if (
typeof maintenanceStartTime === 'string' ||
maintenanceStartTime === null
) {
updateData.maintenanceStartTime = maintenanceStartTime
? new Date(maintenanceStartTime)
: null;
}
if (
typeof maintenanceEndTime === 'string' ||
maintenanceEndTime === null
) {
updateData.maintenanceEndTime = maintenanceEndTime
? new Date(maintenanceEndTime)
: null;
}
const existingSettings = await prisma.systemSettings.findFirst({
select: { id: true },
});
let settings: SystemSettingsRecord;
if (existingSettings) {
settings = await prisma.systemSettings.update({
where: { id: existingSettings.id },
data: updateData,
select: systemSettingsSelect,
});
} else {
const createData: Prisma.SystemSettingsCreateInput = {
headerBgColor:
typeof headerBgColor === 'string' && headerBgColor
? headerBgColor
: DEFAULT_HEADER_BG_COLOR,
headerLogoUrl:
normalizedHeaderLogoUrl || DEFAULT_HEADER_LOGO_URL,
loginLogoUrl:
normalizedLoginLogoUrl || DEFAULT_LOGIN_LOGO_URL,
siteName:
typeof siteName === 'string' && siteName
? siteName
: DEFAULT_SITE_NAME,
s3Endpoint: typeof s3Endpoint === 'string' ? s3Endpoint : '',
s3AccessKey: isCredentialRotationRequested ? nextS3AccessKey : '',
s3SecretKey: isCredentialRotationRequested ? nextS3SecretKey : '',
s3Bucket: typeof s3Bucket === 'string' ? s3Bucket : '',
s3Region:
typeof s3Region === 'string' && s3Region
? s3Region
: 'us-east-1',
s3PublicUrl: typeof s3PublicUrl === 'string' ? s3PublicUrl : '',
maintenanceEnabled:
typeof maintenanceEnabled === 'boolean'
? maintenanceEnabled
: false,
maintenanceMessage:
typeof maintenanceMessage === 'string'
? maintenanceMessage
: null,
maintenanceStartTime:
typeof maintenanceStartTime === 'string' && maintenanceStartTime
? new Date(maintenanceStartTime)
: null,
maintenanceEndTime:
typeof maintenanceEndTime === 'string' && maintenanceEndTime
? new Date(maintenanceEndTime)
: null,
};
settings = await prisma.systemSettings.create({
data: createData,
select: systemSettingsSelect,
});
}
const s3CredentialsConfigured = isCredentialRotationRequested
? true
: await hasStoredS3Credentials(settings.id);
return jsonNoStore(
toAdminSettingsDto(settings, s3CredentialsConfigured),
);
} catch (error) {
console.error('Update system settings error:', error);
return jsonNoStore({ error: '更新系统设置失败' }, 500);
}
}