Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 路径):
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
67 changes: 55 additions & 12 deletions server/src/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,29 +352,52 @@ 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, timeoutMs } = req.body as {
configId?: string;
method: string;
path: string;
body?: string;
headers?: Record<string, string>;
inlineUrl?: string;
inlineUsername?: string;
inlinePassword?: string;
timeoutMs?: number;
};

if (!configId) {
res.status(400).json({ error: 'configId required', code: 'CONFIG_ID_REQUIRED' });
// 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 = db.prepare('SELECT * FROM webdav_configs WHERE id = ?').get(configId) as Record<string, unknown> | undefined;
if (!webdavConfig) {
const webdavConfig = configId
? (db.prepare('SELECT * FROM webdav_configs WHERE id = ?').get(configId) as Record<string, unknown> | undefined)
: undefined;

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 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;
}

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');

Expand All @@ -394,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' });
Expand Down
106 changes: 104 additions & 2 deletions server/tests/routes/webdavProxyRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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];
Expand All @@ -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 = '<?xml version="1.0"?><D:multistatus xmlns:D="DAV:"><D:response><D:href>/backup/a.json</D:href></D:response></D:multistatus>';
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: '<D:multistatus/>', 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')}`);
});
});
28 changes: 25 additions & 3 deletions src/services/backendAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,14 +623,36 @@ class BackendAdapter {

// === WebDAV Proxy ===

async proxyWebDAV(configId: string, method: string, path: string, body?: string, headers?: Record<string, string>): Promise<Response> {
async proxyWebDAV(
configId: string,
method: string,
path: string,
body?: string,
headers?: Record<string, string>,
inlineConfig?: { url: string; username: string; password: string },
signal?: AbortSignal,
timeoutMs?: number,
): Promise<Response> {
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,
// WebDAV 大文件上传可达 300s;不放宽的话代理会先于调用方超时。
...(timeoutMs ? { timeoutMs } : {}),
// 配置尚未同步到后端时(例如"测试连接"发生在保存之前),由前端直接带上凭据
...(inlineConfig ? {
inlineUrl: inlineConfig.url,
inlineUsername: inlineConfig.username,
inlinePassword: inlineConfig.password,
} : {}),
}),
...(signal ? { signal } : {}),
// 调用方自带的 AbortController 负责业务级超时;这里只兜底一个硬上限,
// 避免 signal 缺失时请求无限挂起。
}, timeoutMs ?? 300000);
}

// === Data Sync ===
Expand Down
19 changes: 19 additions & 0 deletions src/services/electronProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name> 公开预览 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<string, string>;
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>;
Expand Down
Loading
Loading