diff --git a/deploy/runtime-config.mjs b/deploy/runtime-config.mjs index ca67372..2cdc4a8 100644 --- a/deploy/runtime-config.mjs +++ b/deploy/runtime-config.mjs @@ -90,10 +90,15 @@ export function readDevelopmentRuntime(root = runtimeRoot, { previous = false } if (typeof webDomain !== 'string' || !/^[a-z0-9.-]+(?::\d+)?$/i.test(webDomain)) { throw new Error('请先在 yunx-test/config/config.json 配置有效的 Web 域名') } + const mailDomain = config.domains?.primary || webDomain + if (typeof mailDomain !== 'string' || !/^[a-z0-9.-]+(?::\d+)?$/i.test(mailDomain)) { + throw new Error('请先在 yunx-test/config/config.json 配置有效的主邮件域名') + } const certificatePath = resolveDevelopmentCertificate(root, previous ? ssl.tls?.previousCertificateRef : ssl.tls?.certificateRef) const privateKeyPath = resolveDevelopmentCertificate(root, previous ? ssl.tls?.previousPrivateKeyRef : ssl.tls?.privateKeyRef, true) return { origin: `https://${webDomain}`, + mailOrigin: `https://${mailDomain}`, certificatePath, privateKeyPath, generation: config.generation diff --git a/frontend/vite.config.js b/frontend/vite.config.js index f9dc39b..822220f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -30,6 +30,21 @@ const developmentHTTPRedirect = (frontendOrigin, host = '127.0.0.1', port = 80) } }) +const developmentBackendProxy = (backendUrl, backendOrigin) => ({ + target: backendUrl, + changeOrigin: true, + secure: false, + configure: (proxy) => { + proxy.on('proxyReq', (proxyRequest) => { + const originURL = new URL(backendOrigin) + proxyRequest.setHeader('origin', backendOrigin) + proxyRequest.setHeader('host', originURL.host) + proxyRequest.setHeader('x-forwarded-proto', originURL.protocol.replace(':', '')) + proxyRequest.removeHeader('referer') + }) + } +}) + // https://vitejs.dev/config/ export default defineConfig(({ mode, command }) => { const env = loadEnv(mode, process.cwd(), '') @@ -37,6 +52,7 @@ export default defineConfig(({ mode, command }) => { const developmentTLS = command === 'serve' ? readDevelopmentTLS() : undefined const runtime = developmentTLS?.runtime const backendOrigin = runtime?.origin + const publicRecoveryOrigin = runtime?.mailOrigin const frontendOrigin = runtime?.origin return { @@ -64,20 +80,10 @@ export default defineConfig(({ mode, command }) => { https: developmentTLS?.options, cors: true, proxy: { - "/api": { - target: backendUrl, - changeOrigin: true, - secure: false, - configure: (proxy) => { - proxy.on('proxyReq', (proxyRequest) => { - const originURL = new URL(backendOrigin) - proxyRequest.setHeader('origin', backendOrigin) - proxyRequest.setHeader('host', originURL.host) - proxyRequest.setHeader('x-forwarded-proto', originURL.protocol.replace(':', '')) - proxyRequest.removeHeader('referer') - }) - } - }, + "/api": developmentBackendProxy(backendUrl, backendOrigin), + // 匿名恢复入口由后端按 Web 域名计算跳转,恢复 API 则必须保持主邮件域名的同源校验。 + "/public-password-recovery": developmentBackendProxy(backendUrl, backendOrigin), + "/rest/api/recovery": developmentBackendProxy(backendUrl, publicRecoveryOrigin), "/attachments": { target: backendUrl, changeOrigin: true, diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index 3b86137..20c398a 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -30,16 +30,24 @@ test('前端构建必须自动同步 Go 嵌入资源', async () => { test('前端入口按需加载 Element Plus,并延迟设置页和写信编辑器', async () => { const main = await read('frontend/src/main.js') + const mainApp = await read('frontend/src/main-app.js') const vite = await read('frontend/vite.config.js') const header = await read('frontend/src/views/components/HomeHeader/HomeHeader.js') assert.doesNotMatch(main, /app\.use\(ElementPlus\)/) assert.doesNotMatch(main, /element-plus\/dist\/index\.css/) - assert.match(main, /element-services\.css/) + assert.match(main, /public-recovery\.js/) + assert.match(mainApp, /element-services\.css/) assert.match(vite, /manualChunks\(id\)/) assert.match(header, /defineAsyncComponent/) assert.match(header, /import\('\.\.\/ConfigSettings\/ConfigSettings\.vue'\)/) }) +test('开发服务器将匿名恢复入口和接口安全转发到后端', async () => { + const vite = await read('frontend/vite.config.js') + assert.match(vite, /"\/public-password-recovery": developmentBackendProxy\(backendUrl, backendOrigin\)/) + assert.match(vite, /"\/rest\/api\/recovery": developmentBackendProxy\(backendUrl, publicRecoveryOrigin\)/) +}) + test('开发启动必须固定运行配置根目录并集中保存日志', async () => { const content = await read('deploy/start-dev.ps1') assert.match(content, /\$env:SMAIL_ROOT = \$runtime\r?\n/) diff --git a/test/frontend/runtime-deployment.test.mjs b/test/frontend/runtime-deployment.test.mjs index 1a45e61..1ca9a9a 100644 --- a/test/frontend/runtime-deployment.test.mjs +++ b/test/frontend/runtime-deployment.test.mjs @@ -21,6 +21,7 @@ test('开发配置只从模拟部署目录读取且不返回凭据字段', async await writeFile(path.join(root, 'ssl/private.key'), 'key', { mode: 0o600 }) const runtime = readDevelopmentRuntime(root) assert.equal(runtime.origin, 'https://mail.example.test') + assert.equal(runtime.mailOrigin, 'https://mail.example.test') assert.equal(runtime.generation, 9) assert.equal(runtime.privateKeyPath, path.join(root, 'ssl/private.key')) assert.equal(JSON.stringify(runtime).includes('not-exported'), false) @@ -31,6 +32,26 @@ test('开发配置只从模拟部署目录读取且不返回凭据字段', async assert.throws(() => readDevelopmentRuntime(root), /事务尚未完成/) }) +test('开发运行配置单独保留主邮件域名供匿名恢复接口校验', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'smail-runtime-recovery-domain-')) + await mkdir(path.join(root, 'config')) + await mkdir(path.join(root, 'ssl')) + await writeFile(path.join(root, 'config/config.json'), JSON.stringify({ + generation: 5, + domains: { web: 'web.example.test', primary: 'mail.example.test' } + })) + await writeFile(path.join(root, 'config/ssl.json'), JSON.stringify({ + meta: { generation: 5 }, + tls: { certificateRef: 'ssl/public.crt', privateKeyRef: 'ssl/private.key' } + })) + await writeFile(path.join(root, 'ssl/public.crt'), 'certificate') + await writeFile(path.join(root, 'ssl/private.key'), 'key', { mode: 0o600 }) + + const runtime = readDevelopmentRuntime(root) + assert.equal(runtime.origin, 'https://web.example.test') + assert.equal(runtime.mailOrigin, 'https://mail.example.test') +}) + test('源码指纹检测编辑且忽略编译嵌入目录', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'smail-build-state-')) for (const name of ['backend', 'frontend', 'plugins/pmail_telegram_push', 'deploy']) {