Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
346 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|