Smail/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js

373 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const field = (path, label, type = 'text', extra = {}) => ({ path: path.split('.'), label, type, ...extra })
const options = entries => entries.map(([value, label]) => ({ value, label }))
const loginOptions = options([
['', '使用默认策略'], ['off', '关闭'], ['new_device', '新设备验证'], ['always', '每次登录验证']
])
const port = (path, label, extra = {}) => field(path, label, 'number', { min: 0, max: 65535, integer: true, ...extra })
const group = (id, title, description, fields) => ({ id, title, description, fields })
const smtpPorts = new Map([['starttls', 587], ['implicit_tls', 465], ['tls', 465], ['plain', 25]])
const normalizedMailSecurity = value => value === 'tls' ? 'implicit_tls' : value
const mailSecurity = config => {
const value = normalizedMailSecurity(config.listener?.security)
return ['starttls', 'implicit_tls', 'both'].includes(value) ? value : 'both'
}
const startTLSMail = config => mailSecurity(config) === 'starttls'
const implicitTLSMail = config => mailSecurity(config) === 'implicit_tls'
const relayConfigured = config => !!(
config.relay?.relayHost || config.relay?.username || config.relay?.password ||
config.relay?.from
)
export function readFormConfig(text) {
try {
const value = JSON.parse(text)
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
} catch {
return {}
}
}
export function getValue(config, path) {
return path.reduce((value, key) => value?.[key], config)
}
export function fieldKey(item) {
return item.path.join('.')
}
// 推荐值只用于用户主动操作,不在读取配置时覆盖已有的自定义端口。
export function getRecommendedPort(config, path) {
switch (path.join('.')) {
case 'http.port':
return config.http?.tlsMode === 'proxy' ? 8080 : 80
case 'http.httpsPort':
return 443
case 'relay.relayPort':
return smtpPorts.get(mailSecurity(config))
case 'listener.port':
return 25
case 'listener.starttlsPort':
return 587
case 'listener.tlsPort':
return 465
case 'imap.tlsPort':
return startTLSMail(config) ? 143 : 993
case 'pop3.port':
return 110
case 'pop3.tlsPort':
return 995
default:
return undefined
}
}
const validPort = value => Number.isInteger(value) && value > 0 && value <= 65535
const networkDatabase = config => config.database?.type !== 'sqlite'
const uploadedCertificate = config => ['managed_upload', 'custom'].includes(config.tls?.mode)
const certificateManagement = config => {
const management = config.tls?.management
if (['web_server', 'project', 'custom'].includes(management)) return management
return ['managed_default', 'default'].includes(config.tls?.mode) ? 'project' : 'custom'
}
const projectManagedCertificate = config => certificateManagement(config) === 'project'
const externalManagedCertificate = config => !projectManagedCertificate(config)
const normalizedDomain = value => value.toLowerCase().replace(/\.$/, '')
const validDomain = value => {
if (typeof value !== 'string' || value !== value.trim()) return false
const domain = normalizedDomain(value)
return domain.length > 0 && domain.length <= 253 && !/^\d+(\.\d+){3}$/.test(domain) &&
domain.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
}
const baseGroups = [
group('site', '站点与域名', '', [
field('domains.primary', '主邮件域名', 'text', { required: true, placeholder: 'example.com' }),
field('domains.web', '网页访问域名', 'text', { required: true, placeholder: 'mail.example.com' }),
field('domains.accepted', '接收邮件的域名', 'list', { required: true, wide: false, hint: '需包含主邮件域名,每个域名单独填写。', placeholder: 'example.com' }),
field('logLevel', '日志级别', 'select', { options: options([['', '默认级别'], ['debug', '调试'], ['info', '信息'], ['warn', '警告'], ['error', '错误']]) })
]),
group('database', '数据库连接', '', [
field('database.name', '数据库名称', 'text', { locked: true, show: networkDatabase }),
field('database.host', '数据库主机', 'text', { required: true, show: networkDatabase, placeholder: '127.0.0.1' }),
port('database.port', '数据库端口', { min: 1, show: networkDatabase }),
field('database.username', '数据库用户名', 'text', { required: true, show: networkDatabase })
]),
group('http', '网页访问服务', '', [
field('http.tlsMode', '加密连接方式', 'select', {
options: options([['', '沿用当前设置'], ['direct', '由 Smail 提供 HTTPS'], ['proxy', '由反向代理提供 HTTPS']]),
hint: '选择内置 HTTPS 自动启用并补全端口;选择反向代理后,Smail 不自动申请或续期 SSL 证书,由反代服务器负责;邮件协议仍需有效的本地证书。'
}),
port('http.port', 'HTTP 监听端口', {
hint: '保留已有自定义端口;未设置时自动补全。反代请转发到此端口,外部 HTTPS 端口在反代服务器配置。'
}),
field('http.httpsEnabled', '启用内置 HTTPS', 'switch', {
show: config => config.http?.tlsMode !== 'proxy',
hint: '开启时自动补全 HTTPS 端口,关闭时清空端口;正式站点须由内置服务或反代提供 HTTPS。'
}),
port('http.httpsPort', 'HTTPS 监听端口', {
min: 1,
show: config => config.http?.tlsMode !== 'proxy' && !!config.http?.httpsEnabled,
hint: '默认使用 443;需要避开端口占用时可手动修改。'
})
])
]
const mailGroups = [
group('listener', '邮件服务监听', '', [
field('listener.security', '统一加密方式', 'select', {
wide: true,
fallback: 'both',
options: [
{ value: 'both', label: '旧配置兼容状态(请选择统一加密方式)', disabled: true },
...options([
['starttls', 'STARTTLS / STLS'],
['implicit_tls', '隐式 TLS']
])
],
hint: '切换后 SMTP 提交、IMAP、POP3 和已配置的 SMTP 中继会统一使用同一种加密方式,并自动匹配推荐端口。SMTP 25 收信端口始终保留。'
}),
port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }),
port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, show: config => !implicitTLSMail(config), hint: '默认 587,客户端升级 TLS 后进行认证。' }),
port('listener.tlsPort', 'SMTP 提交端口(隐式 TLS)', { min: 1, show: config => !startTLSMail(config), hint: '默认 465,客户端建立连接时直接使用 TLS。' }),
port('imap.tlsPort', 'IMAP 端口', { min: 1, required: true, dynamicLabel: config => startTLSMail(config) ? 'IMAP 端口(STARTTLS)' : 'IMAP 端口(隐式 TLS)', hint: 'STARTTLS 默认使用 143,隐式 TLS 默认使用 993。' }),
port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, show: config => !implicitTLSMail(config), hint: '默认 110,客户端使用 STLS 升级加密后认证。' }),
port('pop3.tlsPort', 'POP3 端口(隐式 TLS)', { min: 1, required: true, show: config => !startTLSMail(config), hint: '默认 995,客户端建立连接时直接使用 TLS。' })
]),
group('relay', 'SMTP 中继连接(可选)', '', [
field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
port('relay.relayPort', 'SMTP 发信中继端口', { hint: '外部 SMTP 服务器端口,与本机监听无关;选择加密方式后自动填写。' }),
field('relay.username', '认证用户名', 'text', { placeholder: '需要认证时填写' }),
field('relay.password', '中继密码', 'password', { placeholder: '留空保持原值' }),
field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }),
field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' })
])
]
const mailPolicyGroups = [
group('policy', '邮件策略', '', [
field('policy.maxMessageBytes', '最大邮件大小(字节)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true, hint: '0 使用后端默认限制;1 MiB = 1048576 字节。' }),
field('policy.spamFilterLevel', '垃圾邮件过滤级别', 'select', { fallback: 0, options: options([[0, '不过滤'], [1, 'SPF 与 DKIM 均失败时过滤'], [2, 'SPF 失败时过滤'], [3, 'DKIM 失败时过滤']]) })
])
]
const securityPolicyGroups = [
group('verification', '登录与账号恢复', '', [
field('relay.verificationEnabled', '启用账号登录验证', 'switch'),
field('relay.loginPolicy', '账号登录验证策略', 'select', { options: loginOptions }),
field('policy.externalRecoveryEnabled', '允许外部邮箱恢复账号', 'switch')
])
]
const sslGroups = [
group('certificate', '证书与存储', '', [
field('tls.management', '证书管理方式', 'select', {
fallback: 'custom',
options: options([
['web_server', 'Web 服务器管理'],
['project', '项目自身管理'],
['custom', '自定义']
])
}),
field('tls.challenge', '证书验证方式', 'select', {
show: projectManagedCertificate,
fallback: 'http',
options: options([['http', 'HTTP 验证'], ['dns', 'DNS 验证']])
}),
field('tls.certificateRef', '证书文件', 'text', {
show: externalManagedCertificate,
placeholder: 'config/ssl/example.com/fullchain.pem'
}),
field('tls.privateKeyRef', '私钥文件', 'text', {
show: externalManagedCertificate,
placeholder: 'config/ssl/example.com/privkey.pem'
})
])
]
const pluginDefinitions = {
spam_block: { label: '垃圾邮件拦截', fields: [
field('url', '检测服务地址', 'text', { placeholder: 'https://filter.example.com' }),
field('timeout', '请求超时(毫秒)', 'number', { min: 0, max: 2147483647, integer: true, step: 100, hint: '1000 毫秒 = 1 秒;0 使用插件默认值 3000 毫秒。' }),
field('threshold', '拦截阈值(百分比)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, step: 0.1, hint: '例如 20 表示 20%;0 使用插件默认值 20%。' })
] },
telegram_push: { label: 'Telegram 推送', fields: [
field('proxy', '代理地址', 'text', { hint: '可选,不使用代理时留空。' }),
field('timeout', '请求超时(秒)', 'number', { min: 0, max: 120, integer: true, hint: '0 使用插件默认值,最大 120 秒。' }),
field('webDomain', '网页域名', 'text'),
field('debug', '启用调试日志', 'switch'),
field('httpsEnabled', '链接使用 HTTPS', 'switch')
] },
wechat_push: { label: '微信推送', fields: [
field('appId', '应用编号(App ID)'),
field('templateId', '消息模板编号'),
field('userId', '接收用户编号'),
field('webDomain', '网页域名')
] }
}
export function getFieldGroups(section, config) {
const pluginId = Object.hasOwn(pluginDefinitions, section) ? section : ''
if (section !== 'plugins' && !pluginId) {
return ({ base: baseGroups, mail: mailGroups, mail_policy: mailPolicyGroups, security_policy: securityPolicyGroups, ssl: sslGroups }[section] || [])
.map(item => ({
...item,
fields: item.fields
.filter(input => !input.show || input.show(config))
.map(input => ({
...input,
label: input.dynamicLabel ? input.dynamicLabel(config) : input.label,
recommendedPort: getRecommendedPort(config, input.path)
}))
}))
.filter(item => item.fields.length > 0)
}
return Object.entries(config.plugins || {}).filter(([id]) => !pluginId || id === pluginId).map(([id, plugin]) => {
const definition = pluginDefinitions[id]
const prefix = ['plugins', id]
const input = (name, label, type, extra = {}) => ({ path: [...prefix, name], label, type, ...extra })
const settingsEditable = plugin.settings == null || (typeof plugin.settings === 'object' && !Array.isArray(plugin.settings))
return {
id,
title: definition?.label || `插件:${id}`,
description: '',
fields: [
input('enabled', '启用插件', 'switch', { locked: !definition }),
input('executable', '运行程序', 'text', { locked: true }),
{ path: [...prefix, 'permissions', 'network'], label: '允许访问的网络目标', type: 'network', hint: '仅允许 HTTPS 或 WSS;填写域名和端口,不接受 IP 地址。', locked: !definition },
...(definition && settingsEditable ? definition.fields.map(item => ({ ...item, path: [...prefix, 'settings', ...item.path] })) : [])
],
note: '',
secretConfigured: !!plugin.secretStatus?.configured
}
})
}
// 只更新明确可编辑的字段,完整保留代次、内部引用以及未来版本的未展示字段。
export function updateFormConfig(text, section, path, value) {
const config = readFormConfig(text)
if (!Array.isArray(path) || path.some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) return text
const key = path.join('.')
const input = getFieldGroups(section, config).flatMap(item => item.fields).find(item => fieldKey(item) === key)
if (!input || input.locked) return text
const previousValue = getValue(config, path)
let parent = config
for (const part of path.slice(0, -1)) {
if (!parent[part] || typeof parent[part] !== 'object') parent[part] = {}
parent = parent[part]
}
parent[path.at(-1)] = value ?? (input.type === 'number' ? null : '')
if (previousValue !== value) {
if (key === 'listener.security' && ['starttls', 'implicit_tls'].includes(value)) {
const startTLS = value === 'starttls'
config.listener.port = 25
config.listener.starttlsPort = startTLS ? 587 : 0
config.listener.tlsPort = startTLS ? 0 : 465
config.imap ||= {}
config.imap.tlsPort = startTLS ? 143 : 993
config.pop3 ||= {}
config.pop3.port = startTLS ? 110 : 0
config.pop3.tlsPort = startTLS ? 0 : 995
config.relay ||= {}
config.relay.security = value
config.relay.relayPort = smtpPorts.get(value)
}
if (key.startsWith('relay.') && key !== 'relay.security' && relayConfigured(config) && ['starttls', 'implicit_tls'].includes(mailSecurity(config))) {
config.relay.security = mailSecurity(config)
if (!validPort(config.relay.relayPort)) config.relay.relayPort = smtpPorts.get(config.relay.security)
}
if (key === 'http.tlsMode' && ['direct', 'proxy'].includes(value)) {
if (!validPort(config.http.port)) {
config.http.port = getRecommendedPort(config, ['http', 'port'])
}
config.http.httpsEnabled = value === 'direct'
config.http.httpsPort = value === 'proxy' ? 0 : (
validPort(config.http.httpsPort) ? config.http.httpsPort : 443
)
}
if (key === 'http.httpsEnabled') {
config.http.httpsPort = value ? (validPort(config.http.httpsPort) ? config.http.httpsPort : 443) : 0
}
if (key === 'tls.management' && value === 'project') config.tls.challenge ||= 'http'
}
if (key === 'tls.mode' && ['managed_default', 'default'].includes(value)) config.tls.customDirectory = ''
return JSON.stringify(config, null, 2)
}
export function validateFormConfig(section, config) {
const errors = {}
for (const item of getFieldGroups(section, config).flatMap(item => item.fields)) {
if (item.locked) continue
const value = getValue(config, item.path)
const key = fieldKey(item)
if (item.required && (value == null || (typeof value === 'string' && !value.trim()) || (Array.isArray(value) && !value.length))) errors[key] = `请填写${item.label}`
if (item.type === 'number' && value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || (item.integer && !Number.isSafeInteger(value)) || value < item.min || value > item.max)) {
errors[key] = `请填写 ${item.min}~${item.max} 范围内的${item.integer ? '整数' : '数值'}`
}
if (item.type === 'list' && value != null && (!Array.isArray(value) || value.some(entry => typeof entry !== 'string' || !entry.trim()) || new Set(value).size !== value.length)) {
errors[key] = '每项都需填写,且不能重复;不需要的项目请移除'
}
if (item.type === 'network' && value != null) {
const valid = Array.isArray(value) && value.every(target => target && ['https', 'wss'].includes(target.scheme) && validDomain(target.host) && Number.isInteger(target.port) && target.port > 0 && target.port <= 65535)
if (!valid || new Set(value.map(target => `${target.scheme}://${normalizedDomain(target.host)}:${target.port}`)).size !== value.length) errors[key] = '请填写不重复的有效协议、域名和端口'
}
}
if (section === 'base') {
const domains = config.domains || {}
for (const name of ['primary', 'web']) {
if (!validDomain(domains[name])) errors[`domains.${name}`] = '请填写有效域名,不包含协议、路径或空格'
}
if (Array.isArray(domains.accepted) && domains.accepted.every(validDomain)) {
const accepted = domains.accepted.map(normalizedDomain)
if (new Set(accepted).size !== accepted.length) errors['domains.accepted'] = '接收域名不能重复,大小写和末尾点视为同一域名'
if (validDomain(domains.primary) && !accepted.includes(normalizedDomain(domains.primary))) errors['domains.accepted'] = '接收域名列表必须包含主邮件域名'
} else {
errors['domains.accepted'] = '请逐项填写有效的接收邮件域名'
}
if (config.http?.httpsEnabled && !config.http.httpsPort) errors['http.httpsPort'] = '启用内置 HTTPS 时请填写 1~65535 的端口'
if (config.http?.httpsEnabled && (config.http.port || 80) === config.http.httpsPort) {
errors['http.httpsPort'] = 'HTTP 与 HTTPS 监听端口不能相同'
}
}
if (section === 'mail') {
const relay = config.relay || {}
const configured = relayConfigured(config)
if (!['starttls', 'implicit_tls'].includes(mailSecurity(config))) {
errors['listener.security'] = '请选择 STARTTLS / STLS 或隐式 TLS'
}
if (configured) {
if (!relay.relayHost) errors['relay.relayHost'] = '请填写 SMTP 服务器'
const relaySecurity = normalizedMailSecurity(relay.security)
if (!['starttls', 'implicit_tls'].includes(relaySecurity)) errors['listener.security'] = '请先选择统一加密方式'
if (['starttls', 'implicit_tls'].includes(mailSecurity(config)) && relaySecurity !== mailSecurity(config)) errors['listener.security'] = 'SMTP 中继加密方式必须与邮件服务保持一致'
if (!validPort(relay.relayPort)) errors['relay.relayPort'] = '请填写 1~65535 的 SMTP 端口'
if (!relay.from) errors['relay.from'] = '请填写发件邮箱'
if (!relay.fromDomain) errors['relay.fromDomain'] = '请填写发件域名'
}
}
if (section === 'mail') {
const paths = getFieldGroups('mail', config).flatMap(group => group.fields)
.map(field => fieldKey(field))
.filter(path => /^(listener\.(port|starttlsPort|tlsPort)|imap\.tlsPort|pop3\.(port|tlsPort))$/.test(path))
for (const path of paths) {
const value = getValue(config, path.split('.'))
if (value > 0 && paths.some(other => other !== path && getValue(config, other.split('.')) === value)) errors[path] = '邮件服务监听端口不能重复'
}
}
if (section === 'ssl') {
const tls = config.tls || {}
const management = certificateManagement(config)
if (!['web_server', 'project', 'custom'].includes(management)) errors['tls.management'] = '请选择证书管理方式'
if (management === 'project' && !['http', 'dns'].includes(tls.challenge || 'http')) errors['tls.challenge'] = '请选择证书验证方式'
const directory = uploadedCertificate(config) ? tls.customDirectory : tls.defaultDirectory
const controlled = value => typeof value === 'string' && !!value && !/^(\/|[a-z]:)/i.test(value) && !/[\\:]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..')
for (const name of ['defaultDirectory', ...(uploadedCertificate(config) ? ['customDirectory'] : [])]) {
if (!controlled(tls[name])) errors[`tls.${name}`] = '请填写受管存储中的相对目录,不允许绝对路径或上级目录'
}
for (const name of ['certificateRef', 'privateKeyRef']) {
if (tls[name] && (!controlled(tls[name]) || !tls[name].startsWith(`${directory}/`))) errors[`tls.${name}`] = '文件引用必须位于当前证书目录内'
}
if (!!tls.certificateRef !== !!tls.privateKeyRef) errors['tls.privateKeyRef'] = '证书与私钥引用必须成对填写'
}
return errors
}