From 59cfb519545cf48bbe758d4d877707e08717f135 Mon Sep 17 00:00:00 2001 From: lostiv Date: Wed, 30 Sep 2026 14:14:21 +0800 Subject: [PATCH 1/2] fix(webdav): route browser requests through the backend proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebDAV service issued all eight requests directly from the browser, so WebDAV servers that do not send CORS headers for DAV methods (PROPFIND, MKCOL, COPY, MOVE, LOCK, ...) failed the preflight and surfaced as "Failed to fetch" / "CORS策略阻止了连接到WebDAV服务器". The backend already exposes POST /api/proxy/webdav (server/src/routes/proxy.ts) and the matching backend.proxyWebDAV() (src/services/backendAdapter.ts), but the frontend never called them - proxyWebDAV had zero call sites in src/. Consolidate the eight WebDAV call sites into a single davFetch() helper that uses the backend proxy when the backend is available and falls back to a direct browser fetch otherwise (so purely static/local deployments keep working). Existing timeout, AbortController, error classification and logger behaviour are preserved. Also let /api/proxy/webdav accept inline credentials, so a connection test can run before the config has been persisted - at that point configId cannot be resolved on the server yet and the test would always fail with 404. --- server/src/routes/proxy.ts | 35 +++++++++++------ src/services/backendAdapter.ts | 19 +++++++++- src/services/webdavService.ts | 68 ++++++++++++++++++++++++---------- 3 files changed, 89 insertions(+), 33 deletions(-) diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 4ab3454bd..ad0e9a284 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -352,29 +352,40 @@ router.post('/api/proxy/ai', async (req, res) => { router.post('/api/proxy/webdav', async (req, res) => { try { const db = getDb(); - const { configId, method, path, body: requestBody, headers: extraHeaders } = req.body as { - configId: string; + const { configId, method, path, body: requestBody, headers: extraHeaders, + inlineUrl, inlineUsername, inlinePassword } = req.body as { + configId?: string; method: string; path: string; body?: string; headers?: Record; + inlineUrl?: string; + inlineUsername?: string; + inlinePassword?: string; }; - if (!configId) { - res.status(400).json({ error: 'configId required', code: 'CONFIG_ID_REQUIRED' }); - return; - } + const webdavConfig = configId + ? (db.prepare('SELECT * FROM webdav_configs WHERE id = ?').get(configId) as Record | undefined) + : undefined; - const webdavConfig = db.prepare('SELECT * FROM webdav_configs WHERE id = ?').get(configId) as Record | undefined; - if (!webdavConfig) { + let password: string; + let username: string; + let baseUrl: string; + + if (webdavConfig) { + password = decrypt(webdavConfig.password_encrypted as string, config.encryptionKey); + username = webdavConfig.username as string; + baseUrl = webdavConfig.url as string; + } else if (inlineUrl && inlineUsername !== undefined && inlinePassword !== undefined) { + // 配置尚未同步到后端(例如"测试连接"发生在保存之前)时,使用请求携带的凭据 + baseUrl = inlineUrl; + username = inlineUsername; + password = inlinePassword; + } else { res.status(404).json({ error: 'WebDAV config not found', code: 'WEBDAV_CONFIG_NOT_FOUND' }); return; } - const password = decrypt(webdavConfig.password_encrypted as string, config.encryptionKey); - const username = webdavConfig.username as string; - const baseUrl = webdavConfig.url as string; - const targetUrl = `${baseUrl}${path}`; const credentials = Buffer.from(`${username}:${password}`).toString('base64'); diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index aa33145ca..3c034da21 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -623,13 +623,28 @@ class BackendAdapter { // === WebDAV Proxy === - async proxyWebDAV(configId: string, method: string, path: string, body?: string, headers?: Record): Promise { + async proxyWebDAV( + configId: string, + method: string, + path: string, + body?: string, + headers?: Record, + inlineConfig?: { url: string; username: string; password: string }, + ): Promise { if (!this._backendUrl) throw new Error('Backend not available'); return this.fetchWithTimeout(`${this._backendUrl}/proxy/webdav`, { method: 'POST', headers: this.getAuthHeaders(), - body: JSON.stringify({ configId, method, path, body, headers }) + body: JSON.stringify({ + configId, method, path, body, headers, + // 配置尚未同步到后端时(例如"测试连接"发生在保存之前),由前端直接带上凭据 + ...(inlineConfig ? { + inlineUrl: inlineConfig.url, + inlineUsername: inlineConfig.username, + inlinePassword: inlineConfig.password, + } : {}), + }) }); } diff --git a/src/services/webdavService.ts b/src/services/webdavService.ts index 94f1ace24..c812bf2c1 100644 --- a/src/services/webdavService.ts +++ b/src/services/webdavService.ts @@ -1,5 +1,6 @@ import { WebDAVConfig } from '../types'; import { logger } from './logger'; +import { backend } from './backendAdapter'; export class WebDAVService { private config: WebDAVConfig; @@ -83,6 +84,45 @@ export class WebDAVService { return `${this.config.url}${basePath}${filename}`; } + // 相对配置 URL 的路径(不含协议与主机),用于交给后端代理 + private getRelativePath(filename: string): string { + const basePath = this.config.path.endsWith('/') ? this.config.path : `${this.config.path}/`; + return `${basePath}${filename}`; + } + + /** + * 发起 WebDAV 请求。 + * 后端可用时改走 POST /api/proxy/webdav,由服务端代为请求,规避浏览器 CORS 限制; + * 后端不可用(纯静态/本地部署)时回退为浏览器直连。 + * @param requestPath 相对配置 URL 的路径,如 "/backup/data.json" + */ + private async davFetch( + method: string, + requestPath: string, + init?: { headers?: Record; body?: string; signal?: AbortSignal }, + ): Promise { + if (backend.isAvailable) { + // Authorization 由后端依据配置生成,避免两份凭据冲突 + const forwardHeaders: Record = { ...(init?.headers ?? {}) }; + delete forwardHeaders['Authorization']; + return backend.proxyWebDAV( + this.config.id, + method, + requestPath, + init?.body, + forwardHeaders, + { url: this.config.url, username: this.config.username, password: this.config.password }, + ); + } + + return fetch(`${this.config.url}${requestPath}`, { + method, + headers: init?.headers, + body: init?.body, + signal: init?.signal, + }); + } + private handleNetworkError(error: unknown, operation: string): never { logger.error('webdav', `WebDAV ${operation} failed`, error); @@ -134,16 +174,14 @@ export class WebDAVService { throw new Error('WebDAV URL必须以 http:// 或 https:// 开头'); } - // 构建用于测试的目录URL(优先测试配置中的 path) - const dirUrl = `${this.config.url}${this.config.path}`; + // 测试配置中的 path(交由 davFetch 决定走后端代理还是浏览器直连) // 先尝试 HEAD 请求检测基本可达性(某些服务器对 PROPFIND/OPTIONS 支持较差) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 try { - const headResponse = await fetch(dirUrl, { - method: 'HEAD', + const headResponse = await this.davFetch('HEAD', this.config.path, { headers: { 'Authorization': this.getAuthHeader(), }, @@ -155,8 +193,7 @@ export class WebDAVService { if (headResponse.ok) return true; // HEAD 不可用时,尝试 PROPFIND(不少服务器返回 207 Multi-Status 表示成功) - const propfindResponse = await fetch(dirUrl, { - method: 'PROPFIND', + const propfindResponse = await this.davFetch('PROPFIND', this.config.path, { headers: { 'Authorization': this.getAuthHeader(), 'Depth': '0', @@ -210,8 +247,7 @@ export class WebDAVService { const startTime = Date.now(); try { - const response = await fetch(this.getFullPath(filename), { - method: 'PUT', + const response = await this.davFetch('PUT', this.getRelativePath(filename), { headers: { 'Authorization': this.getAuthHeader(), 'Content-Type': 'application/json', @@ -283,10 +319,8 @@ export class WebDAVService { for (const seg of segments) { currentPath += `/${seg}`; - const full = `${this.config.url}${currentPath}`; try { - const res = await fetch(full, { - method: 'MKCOL', + const res = await this.davFetch('MKCOL', currentPath, { headers: { 'Authorization': this.getAuthHeader() }, }); @@ -317,8 +351,7 @@ export class WebDAVService { const startTime = Date.now(); try { - const response = await fetch(this.getFullPath(filename), { - method: 'GET', + const response = await this.davFetch('GET', this.getRelativePath(filename), { headers: { 'Authorization': this.getAuthHeader(), }, @@ -371,8 +404,7 @@ export class WebDAVService { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 - const response = await fetch(this.getFullPath(filename), { - method: 'HEAD', + const response = await this.davFetch('HEAD', this.getRelativePath(filename), { headers: { 'Authorization': this.getAuthHeader(), }, @@ -397,8 +429,7 @@ export class WebDAVService { const basePath = this.config.path.endsWith('/') ? this.config.path : `${this.config.path}/`; const collectionUrl = `${this.config.url}${basePath}`; - const response = await fetch(collectionUrl, { - method: 'PROPFIND', + const response = await this.davFetch('PROPFIND', basePath, { headers: { 'Authorization': this.getAuthHeader(), 'Depth': '1', @@ -528,8 +559,7 @@ export class WebDAVService { // 新增:获取服务器信息 async getServerInfo(): Promise<{ server?: string; davLevel?: string }> { try { - const response = await fetch(this.config.url, { - method: 'OPTIONS', + const response = await this.davFetch('OPTIONS', '', { headers: { 'Authorization': this.getAuthHeader(), }, From 6deda5f544d292236231f95fe934a3db2fc8e03b Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Wed, 30 Sep 2026 15:49:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(webdav):=20=E4=BF=AE=E6=AD=A3=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E9=93=BE=E8=B7=AF=E7=9A=84=E8=B6=85=E6=97=B6=E3=80=81?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E5=93=8D=E5=BA=94=E4=B8=8E=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=AB=AF=E4=BC=A0=E8=BE=93=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review 与自查发现的问题: 1. davFetch 未把 AbortSignal / timeoutMs 透传给 proxyWebDAV,导致 testConnection 的 10s 超时与大文件上传的动态超时(最长 300s)全部失效; 服务端固定 60s 超时还会先于客户端 abort 把上传掐断。现在两端的 timeoutMs 一致传递,服务端按客户端值取值并夹在 1s~300s。 2. 服务端用 res.json() 回包,PROPFIND 的 207 XML 会被二次编码成带引号的 JSON 字符串,listFiles() 无法解析。改为 preserveRawResponse + 原样 send,并透传上游 Content-Type;204/304/HEAD 不带 body。 3. 恢复「缺少 configId 且无内联凭据 → 400 CONFIG_ID_REQUIRED」的契约 (此前被并入 404),并拒绝非绝对路径:baseUrl 与 `@host/x` 拼接会形成 userinfo 形式的跨主机 URL。 4. 桌面版(file:// 源 + webSecurity:true + 不带后端)此前会退回浏览器直连, 等于该修复对 Electron 用户无效。新增 webdav-request IPC 通道,由主进程 用 undici 代发(复用应用内代理),并把结果还原成标准 Response; 主进程侧限制 http(s) 协议、DAV 方法白名单并过滤含 CRLF 的头值。 传输层优先级:Electron IPC → 后端代理 → 浏览器直连。 --- electron/main.js | 71 ++++++++++ electron/preload.js | 2 + server/src/routes/proxy.ts | 38 +++++- server/tests/routes/webdavProxyRoute.test.ts | 106 ++++++++++++++- src/services/backendAdapter.ts | 11 +- src/services/electronProxy.ts | 19 +++ src/services/webdavService.test.ts | 136 +++++++++++++++++++ src/services/webdavService.ts | 61 ++++++++- 8 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 src/services/webdavService.test.ts diff --git a/electron/main.js b/electron/main.js index 8b383d9ac..e9e12df6c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -403,6 +403,77 @@ ipcMain.handle('telegram-fetch-channel', async (_event, channel, before) => { } }); +// WebDAV:主进程代发 DAV 请求。 +// 渲染进程开启了 webSecurity,且桌面版以 file:// 为源、又不携带后端, +// 浏览器直连会被 WebDAV 服务器的 CORS 策略拦下(PROPFIND/MKCOL/PUT 等)。 +// 用 Node fetch(undici)代发可复用应用内已配置的代理,且不受渲染进程 CORS 约束。 +// 主机不设白名单:WebDAV 普遍部署在用户自有的云盘或局域网 NAS 上。 +const WEBDAV_ALLOWED_METHODS = new Set([ + 'GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS', + 'PROPFIND', 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'LOCK', 'UNLOCK', +]); +ipcMain.handle('webdav-request', async (_event, params) => { + const { url, method, headers, body, timeoutMs } = params ?? {}; + if (typeof url !== 'string') { + return { success: false, error: 'invalid url' }; + } + let parsed; + try { + parsed = new URL(url); + } catch { + return { success: false, error: 'invalid url' }; + } + // 仅允许 http(s):避免 file:/data: 等本地协议被主进程读取 + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { success: false, error: 'unsupported protocol' }; + } + const upperMethod = typeof method === 'string' ? method.toUpperCase() : ''; + if (!WEBDAV_ALLOWED_METHODS.has(upperMethod)) { + return { success: false, error: 'invalid method' }; + } + // 头值必须是非 CRLF 字符串,防止请求头注入 + const safeHeaders = {}; + if (headers && typeof headers === 'object') { + for (const [key, value] of Object.entries(headers)) { + if (typeof value !== 'string' || /[\r\n]/.test(key) || /[\r\n]/.test(value)) continue; + safeHeaders[key] = value; + } + } + // 与前端一致的超时上限(WebDAV 上传最长 300s) + const timeout = Number.isFinite(timeoutMs) + ? Math.min(Math.max(Math.trunc(timeoutMs), 1000), 300000) + : 60000; + try { + const dispatcher = getFetchDispatcher(); + const response = await fetch(parsed.toString(), { + method: upperMethod, + headers: safeHeaders, + // GET/HEAD 带 body 会被 undici 拒绝 + ...(body && upperMethod !== 'GET' && upperMethod !== 'HEAD' ? { body } : {}), + signal: AbortSignal.timeout(timeout), + ...(dispatcher ? { dispatcher } : {}), + }); + const text = await response.text(); + return { + success: true, + status: response.status, + statusText: response.statusText, + body: text, + contentType: response.headers.get('content-type') ?? undefined, + }; + } catch (error) { + // 归一超时:渲染进程据此复用既有的 AbortError 提示文案 + const isTimeout = error?.name === 'TimeoutError' || + error?.name === 'AbortError' || + /timeout/i.test(error instanceof Error ? error.message : String(error)); + return { + success: false, + timedOut: isTimeout, + error: error instanceof Error ? error.message : String(error), + }; + } +}); + // X 推文频道鉴权路径:主进程代发 x.com GraphQL / 静态资源 GET 请求 // (带用户的 auth_token/ct0 Cookie;使用 Node fetch 保持 TLS 指纹并规避 Chromium 对自定义 Header 的限制) // 只允许受控操作对应的 URL(调用方不可任意指定 x.com 路径): diff --git a/electron/preload.js b/electron/preload.js index 4d1656864..cfaf2099a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -12,6 +12,8 @@ if (process.isMainFrame) contextBridge.exposeInMainWorld('electronAPI', { clear: () => ipcRenderer.invoke('x-auth:clear'), }, telegramFetchChannel: (channel, before) => ipcRenderer.invoke('telegram-fetch-channel', channel, before), + // WebDAV:主进程代发 DAV 请求,绕开渲染进程的 CORS 限制 + webdavRequest: (params) => ipcRenderer.invoke('webdav-request', params), desktop: { getPrefs: () => ipcRenderer.invoke('desktop:getPrefs'), setAutoLaunch: (enabled) => ipcRenderer.invoke('desktop:setAutoLaunch', enabled), diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index ad0e9a284..7d735edcc 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -353,7 +353,7 @@ router.post('/api/proxy/webdav', async (req, res) => { try { const db = getDb(); const { configId, method, path, body: requestBody, headers: extraHeaders, - inlineUrl, inlineUsername, inlinePassword } = req.body as { + inlineUrl, inlineUsername, inlinePassword, timeoutMs } = req.body as { configId?: string; method: string; path: string; @@ -362,8 +362,16 @@ router.post('/api/proxy/webdav', async (req, res) => { inlineUrl?: string; inlineUsername?: string; inlinePassword?: string; + timeoutMs?: number; }; + // path 必须是同源相对路径:否则 `https://dav.example.com` + `@evil.com/x` + // 会拼出 userinfo 形式的跨主机 URL。 + if (typeof path !== 'string' || !path.startsWith('/')) { + res.status(400).json({ error: 'path must be an absolute path', code: 'WEBDAV_PATH_INVALID' }); + return; + } + const webdavConfig = configId ? (db.prepare('SELECT * FROM webdav_configs WHERE id = ?').get(configId) as Record | undefined) : undefined; @@ -381,6 +389,10 @@ router.post('/api/proxy/webdav', async (req, res) => { baseUrl = inlineUrl; username = inlineUsername; password = inlinePassword; + } else if (!configId) { + // 既没有已保存配置也没有内联凭据:属于请求参数缺失,而非"配置不存在" + res.status(400).json({ error: 'configId required', code: 'CONFIG_ID_REQUIRED' }); + return; } else { res.status(404).json({ error: 'WebDAV config not found', code: 'WEBDAV_CONFIG_NOT_FOUND' }); return; @@ -405,19 +417,39 @@ router.post('/api/proxy/webdav', async (req, res) => { } const proxyConfig = getProxyConfig(); + // 大文件上传的客户端超时可达 300s,服务端必须不早于客户端超时, + // 否则代理会先于客户端 abort,上传被服务端单方面掐断。 + const effectiveTimeout = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) + ? Math.min(Math.max(Math.trunc(timeoutMs), 1000), 300000) + : 60000; const result = await proxyRequest({ url: targetUrl, method, headers, body: requestBody, - timeout: 60000, + timeout: effectiveTimeout, proxyConfig, // 用户自有配置来源的 WebDAV 地址:放行回环/私有网段(局域网 NAS 等) allowPrivate: true, + // PROPFIND 返回 XML、错误返回 HTML:必须原样透传, + // 否则 res.json() 会二次编码,listFiles() 拿到的 XML 无法解析。 + preserveRawResponse: true, }); relayRateLimitHeaders(res, result.headers); - res.status(result.status).json(result.data); + + // 204/304 不允许带 body;HEAD 的响应体本身为空 + const bodyless = result.status === 204 || result.status === 304 || method === 'HEAD'; + const contentType = result.headers['content-type']; + if (contentType && !bodyless) { + res.setHeader('Content-Type', contentType); + } + res.status(result.status); + if (bodyless) { + res.end(); + } else { + res.send(typeof result.data === 'string' ? result.data : String(result.data ?? '')); + } } catch (err) { logger.errorFromError('proxy.webdav', 'WebDAV proxy error', err); res.status(500).json({ error: 'WebDAV proxy failed', code: 'WEBDAV_PROXY_FAILED' }); diff --git a/server/tests/routes/webdavProxyRoute.test.ts b/server/tests/routes/webdavProxyRoute.test.ts index 1499d6e25..29128d4d6 100644 --- a/server/tests/routes/webdavProxyRoute.test.ts +++ b/server/tests/routes/webdavProxyRoute.test.ts @@ -9,6 +9,8 @@ vi.mock('../../src/db/connection.js', () => ({ prepare: (sql: string) => ({ get: (keyOrId: string) => { if (sql.includes('FROM webdav_configs')) { + // 模拟"配置尚未同步到后端":该 id 在库中不存在 + if (keyOrId === 'not-synced-yet') return undefined; return { id: keyOrId, username: 'alice', @@ -47,7 +49,7 @@ const createTestApp = () => { describe('WebDAV proxy route', () => { beforeEach(() => { proxyRequestMock.mockReset(); - proxyRequestMock.mockResolvedValue({ status: 200, data: { ok: true }, headers: {} }); + proxyRequestMock.mockResolvedValue({ status: 200, data: '{"ok":true}', headers: {} }); }); it('strips client Authorization headers case-insensitively before adding proxy auth', async () => { @@ -66,7 +68,7 @@ describe('WebDAV proxy route', () => { 'Content-Type': 'application/json', }, }) - .expect(200, { ok: true }); + .expect(200, '{"ok":true}'); expect(proxyRequestMock).toHaveBeenCalledOnce(); const options = proxyRequestMock.mock.calls[0][0]; @@ -76,4 +78,104 @@ describe('WebDAV proxy route', () => { expect(options.headers['Content-Type']).toBe('application/json'); expect(options.headers.Authorization).toBe(`Basic ${Buffer.from('alice:secret').toString('base64')}`); }); + + it('passes PROPFIND XML through untouched so the client can parse it', async () => { + const app = createTestApp(); + const xml = '/backup/a.json'; + proxyRequestMock.mockResolvedValue({ + status: 207, + data: xml, + headers: { 'content-type': 'application/xml; charset=utf-8' }, + }); + + const res = await request(app) + .post('/api/proxy/webdav') + .send({ configId: 'webdav-1', method: 'PROPFIND', path: '/backup', headers: { Depth: '1' } }) + .expect(207); + + // 原样透传:不能被 res.json() 二次编码成带引号的 JSON 字符串 + expect(res.text).toBe(xml); + expect(res.headers['content-type']).toMatch(/application\/xml/); + expect(proxyRequestMock.mock.calls[0][0].preserveRawResponse).toBe(true); + }); + + it('relays 207 Multi-Status instead of collapsing it', async () => { + const app = createTestApp(); + proxyRequestMock.mockResolvedValue({ status: 207, data: '', headers: {} }); + await request(app) + .post('/api/proxy/webdav') + .send({ configId: 'webdav-1', method: 'PROPFIND', path: '/backup' }) + .expect(207); + }); + + it('sends no body for HEAD requests', async () => { + const app = createTestApp(); + proxyRequestMock.mockResolvedValue({ status: 200, data: '', headers: {} }); + const res = await request(app) + .post('/api/proxy/webdav') + .send({ configId: 'webdav-1', method: 'HEAD', path: '/backup.json' }) + .expect(200); + expect(res.text).toBe(''); + }); + + it('returns 400 when neither a saved config nor inline credentials are supplied', async () => { + const app = createTestApp(); + + const res = await request(app) + .post('/api/proxy/webdav') + .send({ method: 'GET', path: '/backup.json' }) + .expect(400); + + expect(res.body.code).toBe('CONFIG_ID_REQUIRED'); + expect(proxyRequestMock).not.toHaveBeenCalled(); + }); + + it('rejects a relative path so the target host cannot be overridden via userinfo', async () => { + const app = createTestApp(); + + const res = await request(app) + .post('/api/proxy/webdav') + .send({ configId: 'webdav-1', method: 'GET', path: '@evil.example.com/x' }) + .expect(400); + + expect(res.body.code).toBe('WEBDAV_PATH_INVALID'); + expect(proxyRequestMock).not.toHaveBeenCalled(); + }); + + it('honours a client-supplied timeout for large uploads, clamped to the max', async () => { + const app = createTestApp(); + proxyRequestMock.mockResolvedValue({ status: 201, data: '', headers: {} }); + + await request(app) + .post('/api/proxy/webdav') + .send({ configId: 'webdav-1', method: 'PUT', path: '/big.json', body: '{}', timeoutMs: 240000 }) + .expect(201); + expect(proxyRequestMock.mock.calls[0][0].timeout).toBe(240000); + + await request(app) + .post('/api/proxy/webdav') + .send({ configId: 'webdav-1', method: 'PUT', path: '/big.json', body: '{}', timeoutMs: 99999999 }) + .expect(201); + expect(proxyRequestMock.mock.calls[1][0].timeout).toBe(300000); + }); + + it('falls back to inline credentials when the config is not synced yet', async () => { + const app = createTestApp(); + + await request(app) + .post('/api/proxy/webdav') + .send({ + configId: 'not-synced-yet', + method: 'GET', + path: '/backup.json', + inlineUrl: 'https://dav.example.com', + inlineUsername: 'bob', + inlinePassword: 'pw', + }) + .expect(200); + + const options = proxyRequestMock.mock.calls[0][0]; + expect(options.url).toBe('https://dav.example.com/backup.json'); + expect(options.headers.Authorization).toBe(`Basic ${Buffer.from('bob:pw').toString('base64')}`); + }); }); diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 3c034da21..d8bd313b5 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -630,6 +630,8 @@ class BackendAdapter { body?: string, headers?: Record, inlineConfig?: { url: string; username: string; password: string }, + signal?: AbortSignal, + timeoutMs?: number, ): Promise { if (!this._backendUrl) throw new Error('Backend not available'); @@ -638,14 +640,19 @@ class BackendAdapter { headers: this.getAuthHeaders(), body: JSON.stringify({ configId, method, path, body, headers, + // WebDAV 大文件上传可达 300s;不放宽的话代理会先于调用方超时。 + ...(timeoutMs ? { timeoutMs } : {}), // 配置尚未同步到后端时(例如"测试连接"发生在保存之前),由前端直接带上凭据 ...(inlineConfig ? { inlineUrl: inlineConfig.url, inlineUsername: inlineConfig.username, inlinePassword: inlineConfig.password, } : {}), - }) - }); + }), + ...(signal ? { signal } : {}), + // 调用方自带的 AbortController 负责业务级超时;这里只兜底一个硬上限, + // 避免 signal 缺失时请求无限挂起。 + }, timeoutMs ?? 300000); } // === Data Sync === diff --git a/src/services/electronProxy.ts b/src/services/electronProxy.ts index ae028ed8e..c4fd6077c 100644 --- a/src/services/electronProxy.ts +++ b/src/services/electronProxy.ts @@ -71,6 +71,25 @@ interface ElectronAPI { xFetchGraphQL?: (url: string, auth: { authToken: string; ct0: string }) => Promise<{ success: boolean; body?: string; error?: string }>; /** Telegram 频道:主进程代抓 t.me/s/ 公开预览 HTML(可选 before 游标翻历史页) */ telegramFetchChannel?: (channel: string, before?: string) => Promise<{ success: boolean; html?: string; error?: string }>; + /** + * WebDAV:主进程代发 DAV 请求(渲染进程 webSecurity 开启且 file:// 源同样受 CORS 约束, + * 桌面版又不带后端,只能由主进程代发)。响应体以文本原样返回,供上层还原 Response。 + */ + webdavRequest?: (params: { + url: string; + method: string; + headers?: Record; + body?: string; + timeoutMs?: number; + }) => Promise<{ + success: boolean; + status?: number; + statusText?: string; + body?: string; + contentType?: string; + error?: string; + timedOut?: boolean; + }>; xAuth?: { save: (auth: { authToken: string; ct0: string }) => Promise<{ success: boolean; error?: string }>; get: () => Promise<{ authToken: string; ct0: string } | null>; diff --git a/src/services/webdavService.test.ts b/src/services/webdavService.test.ts new file mode 100644 index 000000000..992eaf47a --- /dev/null +++ b/src/services/webdavService.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebDAVService } from './webdavService'; +import { backend } from './backendAdapter'; + +vi.mock('./backendAdapter', () => ({ + backend: { + isAvailable: false, + proxyWebDAV: vi.fn(), + }, +})); + +const proxyWebDAV = vi.mocked(backend.proxyWebDAV); + +const config = { + id: 'webdav-1', + name: 'My NAS', + url: 'https://dav.example.com', + username: 'alice', + password: 'secret', + path: '/backup', + isActive: true, +}; + +const davService = () => new WebDAVService(config); + +describe('WebDAVService 传输层选择', () => { + beforeEach(() => { + vi.mocked(backend).isAvailable = false; + proxyWebDAV.mockReset(); + vi.mocked(window.fetch).mockReset(); + delete window.electronAPI; + }); + + afterEach(() => { + delete window.electronAPI; + }); + + it('无可用后端时回退为浏览器直连', async () => { + vi.mocked(window.fetch).mockResolvedValue(new Response('{}', { status: 200 })); + + await davService().downloadFile('data.json'); + + expect(window.fetch).toHaveBeenCalledTimes(1); + const [url, init] = vi.mocked(window.fetch).mock.calls[0]; + expect(url).toBe('https://dav.example.com/backup/data.json'); + expect(init?.method).toBe('GET'); + expect(proxyWebDAV).not.toHaveBeenCalled(); + }); + + it('后端可用时走后端代理,且不透传 Authorization', async () => { + vi.mocked(backend).isAvailable = true; + proxyWebDAV.mockResolvedValue(new Response('{}', { status: 200 })); + + await davService().downloadFile('data.json'); + + expect(window.fetch).not.toHaveBeenCalled(); + const [configId, method, path, body, headers] = proxyWebDAV.mock.calls[0]; + expect(configId).toBe('webdav-1'); + expect(method).toBe('GET'); + expect(path).toBe('/backup/data.json'); + expect(body).toBeUndefined(); + // Authorization 由后端依据配置生成,前端不重复携带 + expect(headers?.Authorization).toBeUndefined(); + }); + + it('把调用方的 AbortSignal 与 timeoutMs 透传给后端代理', async () => { + vi.mocked(backend).isAvailable = true; + proxyWebDAV.mockResolvedValue(new Response('{}', { status: 200 })); + + const controller = new AbortController(); + await davService().fileExists('data.json'); + + // fileExists 内部自建 controller;这里只断言 timeoutMs 被显式传递 + const call = proxyWebDAV.mock.calls[0]; + expect(call[6]).toBeInstanceOf(AbortSignal); + expect(call[7]).toBe(10000); + expect(controller.signal.aborted).toBe(false); + }); + + it('上传时透传按体积计算出的动态超时', async () => { + vi.mocked(backend).isAvailable = true; + proxyWebDAV.mockResolvedValue(new Response('', { status: 201 })); + + const big = 'x'.repeat(2 * 1024 * 1024); + await davService().uploadFile('big.json', big); + + const putCall = proxyWebDAV.mock.calls.find((call) => call[1] === 'PUT'); + expect(putCall).toBeDefined(); + // 2MB → 2048KB * 100ms = 204800ms,落在 60s~300s 区间 + expect(putCall![7]).toBe(204800); + }); + + it('桌面版走主进程 IPC,不触发浏览器 fetch 与后端代理', async () => { + const webdavRequest = vi.fn().mockResolvedValue({ + success: true, + status: 200, + statusText: 'OK', + body: '{"restored":true}', + }); + window.electronAPI = { webdavRequest } as unknown as Window['electronAPI']; + vi.mocked(backend).isAvailable = true; + + const restored = await davService().downloadFile('data.json'); + + expect(restored).toBe('{"restored":true}'); + expect(window.fetch).not.toHaveBeenCalled(); + expect(proxyWebDAV).not.toHaveBeenCalled(); + const params = webdavRequest.mock.calls[0][0]; + expect(params.url).toBe('https://dav.example.com/backup/data.json'); + expect(params.method).toBe('GET'); + expect(params.headers.Authorization).toMatch(/^Basic /); + }); + + it('桌面版把 IPC 超时归一成 AbortError,沿用既有超时文案', async () => { + window.electronAPI = { + webdavRequest: vi.fn().mockResolvedValue({ success: false, timedOut: true, error: 'timeout' }), + } as unknown as Window['electronAPI']; + + await expect(davService().downloadFile('data.json')).rejects.toThrow('下载超时'); + }); + + it('桌面版 PROPFIND 保留 207 状态与原始 XML', async () => { + const xml = '/backup/a.json'; + window.electronAPI = { + webdavRequest: vi.fn().mockResolvedValue({ + success: true, + status: 207, + statusText: 'Multi-Status', + body: xml, + contentType: 'application/xml; charset=utf-8', + }), + } as unknown as Window['electronAPI']; + + await expect(davService().listFiles()).resolves.toEqual(['a.json']); + }); +}); diff --git a/src/services/webdavService.ts b/src/services/webdavService.ts index c812bf2c1..2397874d6 100644 --- a/src/services/webdavService.ts +++ b/src/services/webdavService.ts @@ -91,16 +91,24 @@ export class WebDAVService { } /** - * 发起 WebDAV 请求。 - * 后端可用时改走 POST /api/proxy/webdav,由服务端代为请求,规避浏览器 CORS 限制; - * 后端不可用(纯静态/本地部署)时回退为浏览器直连。 + * 发起 WebDAV 请求,按可用性依次选择传输层: + * 1. Electron 桌面版:主进程代发(渲染进程没有后端,且 file:// 源同样受 CORS 约束) + * 2. 后端可用:POST /api/proxy/webdav,由服务端代为请求 + * 3. 纯浏览器 / 静态部署:回退为浏览器直连 * @param requestPath 相对配置 URL 的路径,如 "/backup/data.json" + * @param init.timeoutMs 业务级超时,需与调用方的 AbortController 保持一致 */ private async davFetch( method: string, requestPath: string, - init?: { headers?: Record; body?: string; signal?: AbortSignal }, + init?: { headers?: Record; body?: string; signal?: AbortSignal; timeoutMs?: number }, ): Promise { + const targetUrl = `${this.config.url}${requestPath}`; + + if (window.electronAPI?.webdavRequest) { + return this.desktopDavFetch(method, targetUrl, init); + } + if (backend.isAvailable) { // Authorization 由后端依据配置生成,避免两份凭据冲突 const forwardHeaders: Record = { ...(init?.headers ?? {}) }; @@ -112,10 +120,12 @@ export class WebDAVService { init?.body, forwardHeaders, { url: this.config.url, username: this.config.username, password: this.config.password }, + init?.signal, + init?.timeoutMs, ); } - return fetch(`${this.config.url}${requestPath}`, { + return fetch(targetUrl, { method, headers: init?.headers, body: init?.body, @@ -123,6 +133,39 @@ export class WebDAVService { }); } + /** + * 桌面版主进程代发 WebDAV 请求,并还原成标准 Response, + * 使上层调用方(依赖 response.ok / status / text())无需区分传输层。 + */ + private async desktopDavFetch( + method: string, + targetUrl: string, + init?: { headers?: Record; body?: string; signal?: AbortSignal; timeoutMs?: number }, + ): Promise { + const result = await window.electronAPI!.webdavRequest!({ + url: targetUrl, + method, + headers: init?.headers ?? {}, + body: init?.body, + timeoutMs: init?.timeoutMs, + }); + + if (!result.success) { + // 归一成 AbortError,让上层沿用既有的超时提示文案 + if (result.timedOut) { + throw new DOMException('WebDAV request timed out', 'AbortError'); + } + throw new TypeError(result.error || 'WebDAV request failed'); + } + + const bodyless = result.status === 204 || result.status === 304 || method === 'HEAD'; + return new Response(bodyless ? null : (result.body ?? ''), { + status: result.status, + statusText: result.statusText, + headers: result.contentType ? { 'Content-Type': result.contentType } : undefined, + }); + } + private handleNetworkError(error: unknown, operation: string): never { logger.error('webdav', `WebDAV ${operation} failed`, error); @@ -186,6 +229,7 @@ export class WebDAVService { 'Authorization': this.getAuthHeader(), }, signal: controller.signal, + timeoutMs: 10000, }); clearTimeout(timeoutId); @@ -198,6 +242,7 @@ export class WebDAVService { 'Authorization': this.getAuthHeader(), 'Depth': '0', }, + timeoutMs: 10000, }); return propfindResponse.ok || propfindResponse.status === 207; @@ -254,6 +299,7 @@ export class WebDAVService { }, body: compressedContent, signal: controller.signal, + timeoutMs: dynamicTimeout, }); clearTimeout(timeoutId); @@ -322,6 +368,7 @@ export class WebDAVService { try { const res = await this.davFetch('MKCOL', currentPath, { headers: { 'Authorization': this.getAuthHeader() }, + timeoutMs: 15000, }); // 201 Created(新建)或 405 Method Not Allowed(已存在)都视为成功 @@ -356,6 +403,7 @@ export class WebDAVService { 'Authorization': this.getAuthHeader(), }, signal: controller.signal, + timeoutMs: 30000, }); clearTimeout(timeoutId); @@ -409,6 +457,7 @@ export class WebDAVService { 'Authorization': this.getAuthHeader(), }, signal: controller.signal, + timeoutMs: 10000, }); clearTimeout(timeoutId); @@ -444,6 +493,7 @@ export class WebDAVService { `, signal: controller.signal, + timeoutMs: 15000, }); clearTimeout(timeoutId); @@ -563,6 +613,7 @@ export class WebDAVService { headers: { 'Authorization': this.getAuthHeader(), }, + timeoutMs: 10000, }); if (response.ok) {