Files
J-Board-Lite/src/actions/admin/settings.ts
2026-04-29 13:46:10 +10:00

221 lines
7.6 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { getAppConfig } from "@/services/app-config";
import { normalizeSiteUrl } from "@/services/site-url";
import { encrypt } from "@/lib/crypto";
import { sendSmtpTestEmail } from "@/services/email";
const settingsSchema = z.object({
siteName: z.string().trim().min(1, "站点名称不能为空"),
siteUrl: z.string().trim().optional(),
subscriptionUrl: z.string().trim().optional(),
supportContact: z.string().trim().optional(),
maintenanceNotice: z.string().trim().optional(),
siteNotice: z.string().trim().optional(),
allowRegistration: z.string().optional(),
emailVerificationRequired: z.string().optional(),
requireInviteCode: z.string().optional(),
autoReminderDispatchEnabled: z.string().optional(),
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
trafficSyncEnabled: z.string().optional(),
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
inviteRewardEnabled: z.string().optional(),
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
inviteRewardCouponId: z.string().trim().optional(),
turnstileSiteKey: z.string().trim().optional(),
turnstileSecretKey: z.string().trim().optional(),
smtpEnabled: z.string().optional(),
smtpHost: z.string().trim().optional(),
smtpPort: z.coerce.number().int().min(1).max(65535).optional(),
smtpSecure: z.string().optional(),
smtpUser: z.string().trim().optional(),
smtpPassword: z.string().optional(),
smtpFromName: z.string().trim().optional(),
smtpFromEmail: z.string().trim().email("发件邮箱格式不正确").optional().or(z.literal("")),
});
const SMTP_TEST_LIMIT = 5;
const SMTP_TEST_WINDOW_SECONDS = 10 * 60;
const smtpTestEmailSchema = z.string().trim().email("请输入正确的测试邮箱");
const smtpTestSettingsSchema = settingsSchema.extend({
smtpTestEmail: smtpTestEmailSchema,
});
type AdminSession = Awaited<ReturnType<typeof requireAdmin>>;
type SettingsActionResult = { ok: true } | { ok: false; error: string };
type SmtpTestActionResult =
| { ok: true }
| { ok: false; error: string; settingsSaved?: boolean };
function formatActionError(error: unknown, fallback: string) {
if (error instanceof z.ZodError) {
return error.issues[0]?.message ?? fallback;
}
if (error instanceof Error && error.message.trim()) {
return error.message.trim();
}
if (typeof error === "string" && error.trim()) {
return error.trim();
}
return fallback;
}
async function assertSmtpTestRateLimit(userId: string) {
const { success } = await rateLimit(
`ratelimit:smtp-test:${userId}`,
SMTP_TEST_LIMIT,
SMTP_TEST_WINDOW_SECONDS,
);
if (!success) {
throw new Error("测试发信过于频繁,请稍后再试");
}
}
function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Awaited<ReturnType<typeof getAppConfig>>) {
const smtpEnabled = parsed.smtpEnabled === "true";
const emailVerificationRequired = parsed.emailVerificationRequired === "true";
const smtpPassword = parsed.smtpPassword?.trim()
? encrypt(parsed.smtpPassword.trim())
: current.smtpPassword;
const next = {
siteName: parsed.siteName,
siteUrl: normalizeSiteUrl(parsed.siteUrl) || null,
subscriptionUrl: normalizeSiteUrl(parsed.subscriptionUrl) || null,
supportContact: parsed.supportContact || null,
maintenanceNotice: parsed.maintenanceNotice || null,
siteNotice: parsed.siteNotice || null,
allowRegistration: parsed.allowRegistration === "true",
emailVerificationRequired,
requireInviteCode: parsed.requireInviteCode === "true",
autoReminderDispatchEnabled: parsed.autoReminderDispatchEnabled === "true",
reminderDispatchIntervalMinutes:
parsed.reminderDispatchIntervalMinutes ?? current.reminderDispatchIntervalMinutes,
trafficSyncEnabled: parsed.trafficSyncEnabled === "true",
trafficSyncIntervalSeconds:
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
inviteRewardEnabled: parsed.inviteRewardEnabled === "true",
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
turnstileSiteKey: parsed.turnstileSiteKey || null,
turnstileSecretKey: parsed.turnstileSecretKey || null,
smtpEnabled,
smtpHost: parsed.smtpHost || null,
smtpPort: parsed.smtpPort ?? current.smtpPort,
smtpSecure: parsed.smtpSecure === "true",
smtpUser: parsed.smtpUser || null,
smtpPassword,
smtpFromName: parsed.smtpFromName || null,
smtpFromEmail: parsed.smtpFromEmail || null,
};
if (next.smtpEnabled || next.emailVerificationRequired) {
if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) {
throw new Error("启用邮件服务或注册邮箱验证前,请完整填写 SMTP 主机、端口和发件邮箱");
}
}
if (next.emailVerificationRequired && !next.smtpEnabled) {
throw new Error("注册邮箱验证需要先开启 SMTP 邮件服务");
}
return next;
}
function revalidateSettingsViews() {
revalidatePath("/admin/settings");
revalidatePath("/login");
revalidatePath("/register");
revalidatePath("/dashboard");
revalidatePath("/subscriptions");
revalidatePath("/admin/nodes");
revalidatePath("/account");
revalidatePath("/admin/commerce");
}
async function persistAppSettings(
session: AdminSession,
parsed: z.infer<typeof settingsSchema>,
message: string,
) {
const current = await getAppConfig();
const next = buildSettingsUpdate(parsed, current);
await prisma.appConfig.upsert({
where: { id: current.id },
create: { id: current.id, ...next },
update: next,
});
await recordAuditLog({
actor: actorFromSession(session),
action: "settings.update",
targetType: "AppConfig",
targetId: current.id,
targetLabel: next.siteName,
message,
});
revalidateSettingsViews();
return next;
}
export async function saveAppSettings(formData: FormData): Promise<SettingsActionResult> {
try {
const session = await requireAdmin();
const parsed = settingsSchema.parse(Object.fromEntries(formData));
await persistAppSettings(session, parsed, "更新系统设置");
return { ok: true };
} catch (error) {
return { ok: false, error: formatActionError(error, "保存失败") };
}
}
export async function testSmtpSettings(formData: FormData): Promise<SmtpTestActionResult> {
let parsed: z.infer<typeof smtpTestSettingsSchema>;
let next: Awaited<ReturnType<typeof persistAppSettings>>;
let adminUserId = "";
try {
const session = await requireAdmin();
adminUserId = session.user.id;
parsed = smtpTestSettingsSchema.parse(Object.fromEntries(formData));
next = await persistAppSettings(session, parsed, "测试发信前更新系统设置");
} catch (error) {
return { ok: false, error: formatActionError(error, "测试邮件发送失败") };
}
if (!next.smtpEnabled) {
return { ok: false, settingsSaved: true, error: "测试发信前请先开启邮件服务" };
}
try {
await assertSmtpTestRateLimit(adminUserId);
} catch (error) {
return {
ok: false,
settingsSaved: true,
error: formatActionError(error, "测试发信过于频繁,请稍后再试"),
};
}
try {
await sendSmtpTestEmail(parsed.smtpTestEmail);
return { ok: true };
} catch (error) {
return {
ok: false,
settingsSaved: true,
error: formatActionError(error, "请检查 SMTP 配置后重试"),
};
}
}