diff --git a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue index 295143e0..15896c9d 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue @@ -77,8 +77,8 @@
-

{{ canWrite ? '编辑配置' : '查看配置' }}

-

{{ canWrite ? '仅处理当前页面的配置,保存后再应用生效。' : '当前页面的脱敏配置内容。' }}

+

{{ canWrite ? '填写配置' : '查看配置' }}

+

{{ canWrite ? '按分组填写内容,验证并保存后再应用生效。' : '当前页面的脱敏配置内容。' }}

@@ -98,24 +98,14 @@ class="config-alert" /> -
-
- 配置内容 JSON - 编辑代次 {{ status.activeGeneration || '-' }} -
- -
+

{{ sensitiveHint }}。

@@ -125,13 +115,14 @@
@@ -150,7 +141,7 @@ {{ savedGeneration ? `应用代次 ${savedGeneration}` : '应用配置' }} -

保存不会立即生效,确认候选配置后再应用。

+

保存不会立即生效;保存后若继续修改内容,需要重新保存才能应用。

{{ validationResult.valid ? '验证通过' : '验证未通过' }} @@ -169,6 +160,7 @@ + + diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css new file mode 100644 index 00000000..23c5c800 --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css @@ -0,0 +1,58 @@ +.config-form { + display: grid; + gap: 24px; + margin-top: 22px; + min-width: 0; +} + +.form-group { + min-width: 0; +} + +.form-group + .form-group { + padding-top: 24px; + border-top: 1px solid var(--pm-border-color); +} + +.form-group-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.form-group-heading h5 { + margin: 0; + color: var(--pm-text-primary); + font-size: 15px; + line-height: 1.5; +} + +.credential-status { + color: var(--pm-text-secondary); + font-size: 12px; +} + +.form-group-description, +.form-empty { + margin: 6px 0 18px; + color: var(--pm-text-secondary); + font-size: 12px; + line-height: 1.7; + overflow-wrap: anywhere; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 20px; +} + +@container config-main (max-width: 620px) { + .form-grid { + grid-template-columns: minmax(0, 1fr); + gap: 18px; + } +} diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js new file mode 100644 index 00000000..5cdcb32c --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -0,0 +1,227 @@ +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 }) + +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('.') +} + +const networkDatabase = config => config.database?.type !== 'sqlite' +const uploadedCertificate = config => ['managed_upload', 'custom'].includes(config.tls?.mode) +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, hint: '需包含主邮件域名,每个域名单独填写。', placeholder: 'example.com' }), + field('domains.accessHosts', '额外允许的访问主机', 'list', { hint: '按需填写域名或 IP 地址,不填写则保持默认访问范围。', placeholder: 'mail.example.com' }), + field('logLevel', '日志级别', 'select', { options: options([['', '默认级别'], ['debug', '调试'], ['info', '信息'], ['warn', '警告'], ['error', '错误']]) }) + ]), + group('database', '数据库连接', '数据库类型保持不变;修改数据库名称或文件请使用右侧“数据库迁移”,避免切换到空数据库。', [ + field('database.type', '数据库类型', 'select', { locked: true, options: options([['sqlite', 'SQLite(本地文件)'], ['mysql', 'MySQL'], ['postgresql', 'PostgreSQL']]) }), + field('database.localRef', '数据库文件', 'text', { locked: true, show: config => !networkDatabase(config) }), + 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', '网页访问服务', '端口和加密方式变更可能需要重启,并影响当前访问地址。', [ + port('http.port', 'HTTP 端口', { hint: '填写 0 使用后端默认值。' }), + field('http.tlsMode', '加密连接方式', 'select', { options: options([['', '使用默认方式'], ['direct', '由 Smail 提供 HTTPS'], ['proxy', '由反向代理提供 HTTPS']]) }), + field('http.httpsEnabled', '启用内置 HTTPS', 'switch', { show: config => config.http?.tlsMode !== 'proxy' }), + port('http.httpsPort', 'HTTPS 端口', { show: config => config.http?.tlsMode !== 'proxy', hint: '启用内置 HTTPS 时需填写有效端口;关闭时 0 表示不使用。' }), + field('http.trustedProxyCidrs', '可信代理网段', 'list', { placeholder: '127.0.0.1/32', hint: '仅填写实际代理的 CIDR 网段,不要无条件信任所有来源。' }) + ]), + group('security', '全局安全选项', '只调整功能开关与站点信息,密码算法和加密密钥由系统维护。', [ + field('security.externalEmailEnabled', '启用外部邮箱功能', 'switch'), + field('security.webAuthnEnabled', '启用通行密钥', 'switch'), + field('security.webAuthnRpId', '通行密钥站点域名', 'text', { show: config => config.security?.webAuthnEnabled, placeholder: 'mail.example.com' }), + field('security.webAuthnRpDisplayName', '通行密钥显示名称', 'text', { show: config => config.security?.webAuthnEnabled, placeholder: 'Smail' }), + field('security.webAuthnRpOrigins', '通行密钥允许的来源', 'list', { show: config => config.security?.webAuthnEnabled, placeholder: 'https://mail.example.com' }) + ]) +] + +const smtpGroups = [ + group('relay', 'SMTP 中继连接', '用于发送验证及通知邮件。未使用中继时可保留空值。', [ + field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }), + port('relay.relayPort', 'SMTP 端口', { hint: '常见端口:STARTTLS 使用 587,隐式 TLS 使用 465。' }), + field('relay.security', '连接加密', 'select', { options: options([['', '使用默认方式'], ['starttls', 'STARTTLS'], ['implicit_tls', '隐式 TLS'], ['tls', 'TLS(兼容模式)'], ['plain', '不加密(仅限本机)']]) }), + field('relay.username', '认证用户名', 'text', { placeholder: '需要认证时填写' }), + field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }), + field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' }) + ]), + group('verification', '验证邮件策略', '启用前请确认服务器、端口、发件邮箱和认证信息完整。', [ + field('relay.verificationEnabled', '启用验证邮件发送', 'switch'), + field('relay.loginPolicy', '中继登录验证策略', 'select', { options: loginOptions }) + ]) +] + +const emailGroups = [ + group('ports', '邮件服务端口', '各服务端口不能重复,最终可用性由后端统一校验。', [ + port('policy.smtpPort', 'SMTP 收信端口', { min: 1, required: true }), + port('policy.imapPort', 'IMAP 端口', { min: 1, required: true }), + port('policy.pop3Port', 'POP3 端口', { min: 1, required: true }) + ]), + 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 失败时过滤']]) }), + field('policy.loginVerificationPolicy', '账号登录验证策略', 'select', { options: loginOptions }), + field('policy.externalRecoveryEnabled', '允许外部邮箱恢复账号', 'switch') + ]) +] + +const sslGroups = [ + group('certificate', '证书与存储', '这里只填写已导入受管存储的相对引用,不会上传文件或绕过证书校验。', [ + field('tls.mode', '证书来源', 'select', { options: options([['managed_default', '系统管理证书'], ['managed_upload', '已导入的自定义证书'], ['default', '默认目录(兼容模式)'], ['custom', '自定义目录(兼容模式)']]) }), + field('tls.defaultDirectory', '默认证书目录', 'text', { required: true, placeholder: 'config/ssl' }), + field('tls.customDirectory', '自定义证书目录', 'text', { required: true, show: uploadedCertificate, placeholder: 'config/ssl/example.com' }), + field('tls.certificateRef', '证书文件引用', 'text', { placeholder: 'config/ssl/example.com/fullchain.pem', hint: '必须位于当前证书目录内,与私钥成对填写。' }), + field('tls.privateKeyRef', '私钥文件引用', 'text', { placeholder: 'config/ssl/example.com/privkey.pem', hint: '填写文件引用,不是私钥内容。' }) + ]) +] + +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) { + if (section !== 'plugins') { + return ({ base: baseGroups, smtp: smtpGroups, email: emailGroups, ssl: sslGroups }[section] || []) + .map(item => ({ ...item, fields: item.fields.filter(input => !input.show || input.show(config)) })) + } + return Object.entries(config.plugins || {}).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: `插件标识:${id}。程序与权限由安装目录维护;凭据请在专用插件设置中管理。`, + 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: !settingsEditable ? '当前插件使用非标准设置格式,已保留原值;请在专用插件页面调整。' : '', + 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 + 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 (key === 'http.tlsMode' && value === 'proxy') { + config.http.httpsEnabled = false + config.http.httpsPort = 0 + } + 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 (section === 'email') { + const ports = ['smtpPort', 'imapPort', 'pop3Port'] + for (const name of ports) { + const value = config.policy?.[name] + if (value > 0 && ports.some(other => other !== name && config.policy?.[other] === value)) errors[`policy.${name}`] = '邮件服务端口不能重复' + } + } + if (section === 'ssl') { + const tls = config.tls || {} + 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 +} diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue new file mode 100644 index 00000000..c05f31fd --- /dev/null +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.css b/frontend/src/views/components/ConfigSettings/ConfigSettings.css index 62034306..9610c6b1 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.css +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.css @@ -168,53 +168,6 @@ font-size: 11px; font-weight: 700; } -.editor-shell { - margin-top: 16px; - overflow: hidden; - border: 1px solid var(--pm-border-color); - border-radius: 10px; - transition: border-color .18s ease; -} -.editor-shell:focus-within { - border-color: var(--config-accent); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--config-accent) 12%, transparent); -} -.editor-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px; - padding: 10px 14px; - border-bottom: 1px solid var(--pm-border-color); - background: var(--pm-bg-primary); - color: var(--pm-text-secondary); - font-size: 11px; -} -.editor-toolbar strong { - color: var(--pm-text-primary); - font-size: 12px; - font-weight: 600; -} -.format-label { - margin-left: 6px; - color: var(--pm-text-secondary); - font-size: 10px; - font-weight: 400; -} -.config-editor :deep(.el-textarea__inner) { - min-height: 240px !important; - padding: 14px 16px; - color: var(--pm-text-primary); - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 12px; - line-height: 1.7; - tab-size: 2; - border: 0; - border-radius: 0; - background: var(--pm-bg-secondary); - box-shadow: none; -} .editor-hint, .action-hint { margin: 8px 0 0; @@ -553,7 +506,6 @@ } } @media (prefers-reduced-motion: reduce) { - .editor-shell, .maintenance-summary, .disclosure-arrow { transition: none; diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index f5d31a39..fe3a0824 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -2,6 +2,7 @@ import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { http } from '@/utils/axios' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' +import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js' const sections = [ { value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' }, @@ -51,11 +52,14 @@ export function usePage(pageKey = 'base') { let sectionRequestId = 0 let disposed = false const editorText = ref('') + const formConfig = computed(() => readFormConfig(editorText.value)) + const formErrors = ref({}) const loadedText = ref('') const sectionError = ref('') const secretValue = ref('') const confirmed = ref(false) const savedGeneration = ref(0) + let savedInput = null const validationResult = ref(null) const backups = ref([]) const rollbackGeneration = ref(0) @@ -79,7 +83,7 @@ export function usePage(pageKey = 'base') { const stateLabel = computed(() => ({ new: '未初始化', bootstrap: '初始化中', ready: '就绪', degraded: '降级' }[status.setupState] || status.setupState || '未知')) const stateTagType = computed(() => ({ ready: 'success', degraded: 'warning', bootstrap: 'info', new: 'danger' }[status.setupState] || 'info')) const secretField = computed(() => { - if (selectedSection.value === 'base') { + if (selectedSection.value === 'base' && ['mysql', 'postgresql'].includes(formConfig.value.database?.type)) { return { label: '数据库密码', description: '只在本次请求内传输,保存后仅保留密文状态。留空表示保持原值。', placeholder: '输入新数据库密码(可选)' } } if (selectedSection.value === 'smtp') { @@ -87,7 +91,28 @@ export function usePage(pageKey = 'base') { } return null }) - const sensitiveHint = computed(() => secretField.value ? '密码字段已脱敏,修改请使用下方输入框' : '当前分区不包含可编辑密文') + const sensitiveHint = computed(() => secretField.value ? '密码不会回填,修改请使用下方独立输入框,留空保持原值' : '系统内部字段自动保留,无需手工填写') + + const invalidateCandidate = () => { + savedGeneration.value = 0 + savedInput = null + validationResult.value = null + confirmed.value = false + } + const canEdit = () => !disposed && canWrite.value && !loading.value && !working.value && !sectionLoading.value && !sectionError.value + const updateField = (path, value) => { + if (!canEdit()) return + const updated = updateFormConfig(editorText.value, selectedSection.value, path, value) + if (updated === editorText.value) return + editorText.value = updated + if (Object.keys(formErrors.value).length) formErrors.value = validateFormConfig(selectedSection.value, formConfig.value) + invalidateCandidate() + } + const updateSecret = value => { + if (!canEdit() || !secretField.value || secretValue.value === value) return + secretValue.value = value + invalidateCandidate() + } const notifyResponseError = (response, fallback) => { if (!response || response.errorNo !== 0) { @@ -130,12 +155,14 @@ export function usePage(pageKey = 'base') { const section = selectedSection.value sectionLoading.value = true sectionError.value = '' + formErrors.value = {} editorText.value = '' loadedText.value = '' secretValue.value = '' confirmed.value = false validationResult.value = null savedGeneration.value = 0 + savedInput = null try { const response = await http.get(`/api/v1/config/${section}`) if (requestId !== sectionRequestId) return @@ -317,7 +344,7 @@ export function usePage(pageKey = 'base') { try { const payload = JSON.parse(editorText.value) if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new Error('配置必须是 JSON 对象') + throw new Error('配置内容不可用,请刷新页面后重试') } if (selectedSection.value === 'base') { delete payload.databasePasswordStatus @@ -329,19 +356,20 @@ export function usePage(pageKey = 'base') { Object.values(payload.plugins).forEach(plugin => { if (plugin && typeof plugin === 'object') { delete plugin.settingsConfigured + delete plugin.secretStatus } }) } return payload } catch (error) { - throw new Error(error.message || 'JSON 格式错误') + throw new Error('配置内容不可用,请刷新页面后重试') } } const buildRequest = () => { const secretChanges = {} - const value = secretValue.value.trim() - if (value && selectedSection.value === 'base') { + const value = secretValue.value + if (value && selectedSection.value === 'base' && secretField.value) { secretChanges['database.password'] = { operation: 'replace', value: { plaintext: value } } } if (value && selectedSection.value === 'smtp') { @@ -355,8 +383,17 @@ export function usePage(pageKey = 'base') { } } + const checkForm = () => { + formErrors.value = validateFormConfig(selectedSection.value, formConfig.value) + const issues = Object.entries(formErrors.value).map(([code, message]) => ({ code, message })) + if (!issues.length) return true + validationResult.value = { valid: false, issues } + ElMessage.warning('请先检查表单中标红的字段') + return false + } + const validateConfig = async () => { - if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return + if (!canEdit() || !checkForm()) return working.value = 'validate' try { const request = buildRequest() @@ -372,13 +409,14 @@ export function usePage(pageKey = 'base') { } const saveConfig = async () => { - if (!canWrite.value || working.value || sectionLoading.value || sectionError.value) return + if (!canEdit() || !checkForm()) return working.value = 'save' try { const request = buildRequest() const response = await http.put(`/api/v1/config/${selectedSection.value}`, request) notifyResponseError(response, '配置保存失败') savedGeneration.value = response.data?.generation || 0 + savedInput = { text: editorText.value, secret: secretValue.value } validationResult.value = { valid: true, generation: savedGeneration.value, issues: [] } await loadStatus() ElMessage.success(`候选配置已保存,代次 ${savedGeneration.value} 等待应用`) @@ -390,7 +428,12 @@ export function usePage(pageKey = 'base') { } const applyConfig = async () => { - if (!canWrite.value || working.value || !savedGeneration.value) return + if (!canEdit() || !savedGeneration.value) return + if (!savedInput || savedInput.text !== editorText.value || savedInput.secret !== secretValue.value) { + invalidateCandidate() + ElMessage.warning('填写内容已变更,请重新保存候选配置后再应用') + return + } working.value = 'apply' try { const response = await http.post(`/api/v1/config/${selectedSection.value}/apply`, { generation: savedGeneration.value }) @@ -493,13 +536,15 @@ export function usePage(pageKey = 'base') { disposed = true sectionRequestId++ secretValue.value = '' + savedInput = null confirmed.value = false migrationToken.value = '' }) return { loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections, - selectedSection, currentSection, sectionLoading, editorText, sectionError, sensitiveHint, secretField, secretValue, + selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret, + sectionError, sensitiveHint, secretField, secretValue, confirmed, savedGeneration, validationResult, canWrite, canRollback, canAudit, canExportAudit, backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed, rollbackPreview, auditLoading, auditError, auditItems, loadAll, loadSection, validateConfig, saveConfig, applyConfig, diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs new file mode 100644 index 00000000..abd3e549 --- /dev/null +++ b/test/frontend/config-form.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' +import { createRequire } from 'node:module' + +const root = new URL('../../frontend/src/views/components/ConfigSettings/', import.meta.url) +const source = readFileSync(new URL('ConfigForm/ConfigForm.js', root), 'utf8') +const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = compileFunction( + source.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }' +)() + +test('五个分区使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => { + const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) + const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc') + for (const name of ['ConfigForm', 'ConfigField', 'ConfigEditor']) { + const template = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8') + const css = readFileSync(new URL(`${name}/${name}.css`, root), 'utf8') + const { descriptor, errors } = parse(template) + assert.deepEqual(errors, []) + assert.doesNotThrow(() => compileScript(descriptor, { id: name, inlineTemplate: true })) + assert.deepEqual(compileStyle({ source: css, id: `data-v-${name}`, scoped: true }).errors, []) + assert.doesNotMatch(template, /&#x[0-9a-f]+;|type="textarea"|配置 JSON 编辑器/i) + } + for (const section of ['base', 'smtp', 'email', 'ssl', 'plugins']) { + const groups = getFieldGroups(section, { plugins: { spam_block: { settings: null } } }) + assert.ok(groups.length) + const keys = groups.flatMap(group => group.fields.map(field => field.path.join('.'))) + assert.equal(keys.length, new Set(keys).size) + assert.ok(groups.every(group => /[\u4e00-\u9fff]/.test(group.title))) + assert.ok(!keys.some(key => /generation|password|encryptionKey|capabilities/.test(key))) + } +}) + +test('单字段更新完整保留内部数据,锁定字段和污染路径无法写入', () => { + const config = { + generation: 8, schemaVersion: 2, database: { type: 'sqlite', localRef: 'test.db', password: null }, + domains: { primary: 'example.com', accepted: ['example.com'] }, + security: { passwordVersion: 'argon2id', encryptionKeyRef: 'system-managed' }, + future: { nested: [true, 1, null] } + } + const text = JSON.stringify(config, null, 2) + const updated = readFormConfig(updateFormConfig(text, 'base', ['domains', 'primary'], 'mail.example.com')) + assert.deepEqual(updated, { ...config, domains: { ...config.domains, primary: 'mail.example.com' } }) + for (const path of [['database', 'type'], ['database', 'localRef'], ['generation'], ['security', 'encryptionKeyRef'], ['__proto__', 'polluted'], ['constructor', 'prototype', 'polluted'], ['relay', 'relayHost']]) { + assert.equal(updateFormConfig(text, 'base', path, 'changed'), text) + } + assert.equal({}.polluted, undefined) +}) + +test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正确联动', () => { + let text = JSON.stringify({ http: { tlsMode: 'direct', httpsEnabled: true, httpsPort: 443 } }) + text = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'proxy') + assert.deepEqual(readFormConfig(text).http, { tlsMode: 'proxy', httpsEnabled: false, httpsPort: 0 }) + assert.equal(updateFormConfig(text, 'base', ['http', 'httpsEnabled'], true), text) + text = updateFormConfig(text, 'base', ['security', 'externalEmailEnabled'], true) + assert.equal(readFormConfig(text).security.externalEmailEnabled, true) + text = updateFormConfig(text, 'base', ['http', 'port'], 8080) + assert.equal(readFormConfig(text).http.port, 8080) + text = updateFormConfig(text, 'base', ['http', 'port'], undefined) + assert.equal(readFormConfig(text).http.port, null) + assert.ok(validateFormConfig('base', readFormConfig(text))['http.port']) + for (const value of ['25', 0, -1, 65536, 1.5, null]) { + assert.ok(validateFormConfig('email', { policy: { smtpPort: value, imapPort: 143, pop3Port: 110 } })['policy.smtpPort']) + } + assert.ok(validateFormConfig('email', { policy: { smtpPort: 143, imapPort: 143, pop3Port: 110 } })['policy.imapPort']) + assert.deepEqual(validateFormConfig('email', { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }), {}) +}) + +test('域名校验兼容大小写和末尾点,拒绝重复项、空白和路径', () => { + const valid = { database: { type: 'sqlite' }, domains: { primary: 'Example.com.', web: 'mail.example.com', accepted: ['example.com'] } } + assert.deepEqual(validateFormConfig('base', valid), {}) + for (const accepted of [[], ['example.com', 'Example.com.'], [''], [' https://example.com'], ['other.example.com'], 'example.com', [null]]) { + assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, accepted } })['domains.accepted']) + } + for (const primary of ['https://example.com', 'example.com/path', 'bad..com', '-bad.com', '127.0.0.1', '例子.com']) { + assert.ok(validateFormConfig('base', { ...valid, domains: { ...valid.domains, primary } })['domains.primary']) + } +}) + +test('插件网络列表拒绝无效和重复目标,空设置可编辑且非标准设置不覆盖', () => { + const make = network => ({ plugins: { telegram_push: { permissions: { network }, settings: null } } }) + const valid = { scheme: 'https', host: 'api.example.com', port: 443 } + assert.deepEqual(validateFormConfig('plugins', make([valid])), {}) + for (const network of [{}, [null], [{ ...valid, host: '127.0.0.1' }], [{ ...valid, host: 'bad..com' }], [{ ...valid, scheme: 'http' }], [{ ...valid, port: null }], [valid, { ...valid, host: 'API.EXAMPLE.COM.' }]]) { + assert.ok(validateFormConfig('plugins', make(network))['plugins.telegram_push.permissions.network']) + } + const text = JSON.stringify(make([])) + const updated = readFormConfig(updateFormConfig(text, 'plugins', ['plugins', 'telegram_push', 'settings', 'timeout'], 30)) + assert.equal(updated.plugins.telegram_push.settings.timeout, 30) + const legacy = JSON.stringify({ plugins: { telegram_push: { settings: ['preserve'] } } }) + assert.equal(updateFormConfig(legacy, 'plugins', ['plugins', 'telegram_push', 'settings', 'timeout'], 30), legacy) +}) + +test('不同插件超时字段使用各自实际单位,不把毫秒当作秒', () => { + const groups = getFieldGroups('plugins', { plugins: { spam_block: { settings: { timeout: 10000 } }, telegram_push: { settings: { timeout: 30 } } } }) + const spamTimeout = groups.find(group => group.id === 'spam_block').fields.find(field => field.path.at(-1) === 'timeout') + const telegramTimeout = groups.find(group => group.id === 'telegram_push').fields.find(field => field.path.at(-1) === 'timeout') + assert.equal(spamTimeout.label, '请求超时(毫秒)') + assert.equal(telegramTimeout.label, '请求超时(秒)') + assert.deepEqual(validateFormConfig('plugins', { plugins: { spam_block: { settings: { timeout: 10000 } } } }), {}) +}) + +test('证书路径始终受管且引用成对,切回默认来源清除自定义目录', () => { + const tls = { mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl/imported', certificateRef: 'config/ssl/imported/cert.pem', privateKeyRef: 'config/ssl/imported/key.pem' } + assert.deepEqual(validateFormConfig('ssl', { tls }), {}) + for (const customDirectory of ['C:/certs', '/etc/ssl', '../certs', 'config/../certs', 'config\\ssl', 'config//ssl']) { + assert.ok(validateFormConfig('ssl', { tls: { ...tls, customDirectory } })['tls.customDirectory']) + } + assert.ok(validateFormConfig('ssl', { tls: { ...tls, privateKeyRef: '' } })['tls.privateKeyRef']) + assert.ok(validateFormConfig('ssl', { tls: { ...tls, certificateRef: 'config/ssl/outside.pem' } })['tls.certificateRef']) + const updated = readFormConfig(updateFormConfig(JSON.stringify({ tls }), 'ssl', ['tls', 'mode'], 'managed_default')) + assert.equal(updated.tls.customDirectory, '') + assert.equal(updated.tls.privateKeyRef, tls.privateKeyRef) +}) diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index d10a51c7..3c122db7 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -5,11 +5,14 @@ import { compileFunction } from 'node:vm' import { createRequire } from 'node:module' import { computed, reactive, ref } from './vue.mjs' +const formSource = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js', import.meta.url), 'utf8') +const formHelpers = compileFunction(formSource.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, updateFormConfig, validateFormConfig }')() const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8') -const createPageWithKey = compileFunction( +const compilePage = compileFunction( source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage(pageKey)', - ['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount'] + ['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore', 'pageKey', 'onBeforeUnmount', 'readFormConfig', 'updateFormConfig', 'validateFormConfig'] ) +const createPageWithKey = (...args) => compilePage(...args, formHelpers.readFormConfig, formHelpers.updateFormConfig, formHelpers.validateFormConfig) const createPage = (...args) => createPageWithKey(...args, 'base', () => {}) function fixture(get, pageKey = 'base', options = {}) { @@ -323,3 +326,114 @@ test('独立页保持验证、保存、应用端点和代次协议,敏感值 assert.equal(page.confirmed.value, false) assert.equal(page.savedGeneration.value, 0) }) + +test('可视化编辑按权限和工作状态保护,编辑会清除过期确认及候选代次', async () => { + const { page, dispose } = fixture(async () => ({ errorNo: 0, data: { relay: { relayHost: 'smtp.example.com' } } }), 'smtp') + await page.loadAll() + const original = page.editorText.value + for (const state of ['loading', 'sectionLoading', 'working', 'sectionError']) { + page[state].value = true + page.updateField(['relay', 'relayHost'], 'blocked.example.com') + page.updateSecret('blocked-value') + assert.equal(page.editorText.value, original) + assert.equal(page.secretValue.value, '') + page[state].value = false + } + const readonly = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', { hasCapability: () => false }).page + readonly.updateField(['relay', 'relayHost'], 'blocked.example.com') + assert.equal(readonly.editorText.value, '') + page.savedGeneration.value = 8 + page.confirmed.value = true + page.validationResult.value = { valid: true } + page.updateField(['relay', 'relayHost'], 'new.example.com') + assert.equal(page.formConfig.value.relay.relayHost, 'new.example.com') + assert.equal(page.savedGeneration.value, 0) + assert.equal(page.confirmed.value, false) + assert.equal(page.validationResult.value, null) + dispose() + page.updateField(['relay', 'relayHost'], 'blocked.example.com') + assert.equal(page.formConfig.value.relay.relayHost, 'new.example.com') +}) + +test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => { + const requests = [] + const { page } = fixture(async () => ({ errorNo: 0, data: { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } } }), 'email', { + http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } } + }) + await page.loadAll() + page.updateField(['policy', 'smtpPort'], null) + await page.validateConfig() + await page.saveConfig() + assert.deepEqual(requests, []) + assert.ok(page.formErrors.value['policy.smtpPort']) + page.updateField(['policy', 'smtpPort'], 2525) + assert.deepEqual(page.formErrors.value, {}) + await page.validateConfig() + assert.deepEqual(requests, ['/api/v1/config/email/validate']) +}) + +test('插件读取状态字段不传回严格解析接口,未展示字段和权限仍保留', async () => { + let request + const plugin = { enabled: true, executable: 'pmail_telegram_push.exe', permissions: { capabilities: ['network', 'userSettings', 'settingsHtml'], network: [] }, settings: { timeout: 30, future: 'preserved' }, secretStatus: { configured: true }, settingsConfigured: true } + const { page } = fixture(async () => ({ errorNo: 0, data: { meta: { generation: 7 }, plugins: { telegram_push: plugin } } }), 'plugins', { + http: { put: async (path, body) => { request = body; return { errorNo: 0, data: { generation: 8 } } } } + }) + await page.loadSection() + page.updateField(['plugins', 'telegram_push', 'settings', 'timeout'], 40) + await page.saveConfig() + const actual = request.payload.plugins.telegram_push + assert.equal(actual.secretStatus, undefined) + assert.equal(actual.settingsConfigured, undefined) + assert.deepEqual(actual.permissions, plugin.permissions) + assert.deepEqual(actual.settings, { timeout: 40, future: 'preserved' }) + assert.equal(request.payload.meta.generation, 7) + assert.deepEqual(request.secretChanges, {}) + assert.equal(page.formConfig.value.plugins.telegram_push.secretStatus.configured, true) +}) + +test('SQLite 不显示或发送密码,网络数据库和中继密码留空保留且不裁剪空格', async () => { + for (const type of ['sqlite', 'mysql', 'postgresql']) { + let request + const { page } = fixture(async () => ({ errorNo: 0, data: { + database: { type, host: 'localhost', port: 3306, username: 'test' }, + domains: { primary: 'example.com', web: 'mail.example.com', accepted: ['example.com'] } + } }), 'base', { + http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } } + }) + await page.loadSection() + assert.equal(!!page.secretField.value, type !== 'sqlite') + await page.validateConfig() + assert.deepEqual(request.secretChanges, {}) + page.secretValue.value = ' test-only-value ' + await page.validateConfig() + assert.deepEqual(request.secretChanges, type === 'sqlite' ? {} : { 'database.password': { operation: 'replace', value: { plaintext: ' test-only-value ' } } }) + } + let request + const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', { + http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } } + }) + await page.loadSection() + page.updateSecret(' smtp-test-only ') + await page.validateConfig() + assert.equal(request.secretChanges['relay.password'].value.plaintext, ' smtp-test-only ') +}) + +test('保存后更改密码或填写内容不能应用旧候选', async () => { + const requests = [] + const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', { + http: { + put: async () => ({ errorNo: 0, data: { generation: 8 } }), + post: async path => { requests.push(path); return { errorNo: 0 } } + } + }) + await page.loadSection() + await page.saveConfig() + page.updateSecret('test-only-value') + assert.equal(page.savedGeneration.value, 0) + await page.applyConfig() + await page.saveConfig() + page.editorText.value = '{"relay":{"relayHost":"new.example.com"}}' + await page.applyConfig() + assert.equal(page.savedGeneration.value, 0) + assert.deepEqual(requests, []) +})