diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index cb14c897..6f4d6a14 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -187,7 +187,7 @@ jobs: fs.mkdirSync('electron', { recursive: true }); } - const mainJsContent = 'const { app, BrowserWindow, Menu, shell, globalShortcut } = require(\\'electron\\');\\n' + + const mainJsContent = 'const { app, BrowserWindow, Menu, shell, globalShortcut, ipcMain } = require(\\'electron\\');\\n' + 'const path = require(\\'path\\');\\n' + 'const fs = require(\\'fs\\');\\n' + 'const isDev = process.env.NODE_ENV === \\'development\\';\\n\\n' + @@ -204,7 +204,8 @@ jobs: ' enableRemoteModule: false,\\n' + ' webSecurity: false,\\n' + ' allowRunningInsecureContent: true,\\n' + - ' devTools: true // 生产环境也允许 DevTools 便于排障\\n' + + ' devTools: true, // 生产环境也允许 DevTools 便于排障\\n' + + ' preload: path.join(__dirname, \\'preload.js\\')\\n' + ' },\\n' + ' icon: path.join(__dirname, \\'../build/icon.png\\'),\\n' + ' titleBarStyle: \\'default\\', // 使用默认标题栏,避免重叠问题\\n' + @@ -375,8 +376,123 @@ jobs: ' mainWindow = null;\\n' + ' });\\n' + '}\\n\\n' + + 'const PROXY_CONFIG_PATH = path.join(app.getPath(\\'userData\\'), \\'proxy-config.json\\');\\n\\n' + + 'function loadProxyConfig() {\\n' + + ' try {\\n' + + ' if (fs.existsSync(PROXY_CONFIG_PATH)) {\\n' + + ' return JSON.parse(fs.readFileSync(PROXY_CONFIG_PATH, \\'utf-8\\'));\\n' + + ' }\\n' + + ' } catch (e) { console.error(\\'Failed to load proxy config:\\', e); }\\n' + + ' return { enabled: false, type: \\'http\\', host: \\'\\', port: 7890 };\\n' + + '}\\n\\n' + + 'function saveProxyConfig(config) {\\n' + + ' fs.writeFileSync(PROXY_CONFIG_PATH, JSON.stringify(config, null, 2));\\n' + + '}\\n\\n' + + 'async function applyProxy(config) {\\n' + + ' if (!mainWindow || mainWindow.isDestroyed()) return;\\n' + + ' if (config.enabled && config.host && config.port) {\\n' + + ' let auth = \\'\\';\\n' + + ' if (config.username) {\\n' + + ' auth = config.password\\n' + + ' ? encodeURIComponent(config.username) + \\':\\' + encodeURIComponent(config.password) + \\'@\\'\\n' + + ' : encodeURIComponent(config.username) + \\'@\\';\\n' + + ' }\\n' + + ' const proxyUrl = config.type === \\'socks5\\'\\n' + + ' ? \\'socks5://\\' + auth + config.host + \\':\\' + config.port\\n' + + ' : \\'http://\\' + auth + config.host + \\':\\' + config.port;\\n' + + ' await mainWindow.webContents.session.setProxy({\\n' + + ' proxyRules: proxyUrl,\\n' + + ' proxyBypassRules: \\';localhost;127.0.0.1\\'\\n' + + ' });\\n' + + ' console.log(\\'[Proxy] Applied:\\', proxyUrl);\\n' + + ' } else {\\n' + + ' await mainWindow.webContents.session.setProxy({ proxyRules: \\'direct://\\' });\\n' + + ' console.log(\\'[Proxy] Disabled, using direct connection\\');\\n' + + ' }\\n' + + '}\\n\\n' + + 'ipcMain.handle(\\'set-proxy\\', async (event, config) => {\\n' + + ' saveProxyConfig(config);\\n' + + ' await applyProxy(config);\\n' + + ' return { success: true };\\n' + + '});\\n\\n' + + 'ipcMain.handle(\\'get-proxy\\', () => {\\n' + + ' return loadProxyConfig();\\n' + + '});\\n\\n' + + 'ipcMain.handle(\\'test-proxy\\', async (event, config) => {\\n' + + ' const net = require(\\'net\\');\\n' + + ' const connectToProxy = () => new Promise((resolve, reject) => {\\n' + + ' const socket = new net.Socket();\\n' + + ' socket.setTimeout(5000);\\n' + + ' socket.on(\\'connect\\', () => resolve(socket));\\n' + + ' socket.on(\\'timeout\\', () => { socket.destroy(); reject(new Error(\\'Connection timeout\\')); });\\n' + + ' socket.on(\\'error\\', (err) => reject(err));\\n' + + ' socket.connect(config.port, config.host);\\n' + + ' });\\n' + + ' try {\\n' + + ' if (config.type === \\'socks5\\') {\\n' + + ' const socket = await connectToProxy();\\n' + + ' return await new Promise((resolve) => {\\n' + + ' const greeting = config.username\\n' + + ' ? Buffer.from([0x05, 0x02, 0x00, 0x02])\\n' + + ' : Buffer.from([0x05, 0x01, 0x00]);\\n' + + ' socket.setTimeout(5000);\\n' + + ' socket.write(greeting);\\n' + + ' let step = 0;\\n' + + ' socket.on(\\'data\\', (data) => {\\n' + + ' if (step === 0) {\\n' + + ' if (data[0] !== 0x05) { socket.destroy(); resolve({ success: false, error: \\'Invalid SOCKS5 version\\' }); return; }\\n' + + ' if (data[1] === 0xFF) { socket.destroy(); resolve({ success: false, error: \\'No acceptable auth method\\' }); return; }\\n' + + ' if (data[1] === 0x02 && config.username && config.password) {\\n' + + ' step = 1;\\n' + + ' const userBuf = Buffer.from(config.username, \\'utf8\\');\\n' + + ' const passBuf = Buffer.from(config.password, \\'utf8\\');\\n' + + ' const authReq = Buffer.alloc(3 + userBuf.length + passBuf.length);\\n' + + ' authReq[0] = 0x01; authReq[1] = userBuf.length;\\n' + + ' userBuf.copy(authReq, 2);\\n' + + ' authReq[2 + userBuf.length] = passBuf.length;\\n' + + ' passBuf.copy(authReq, 3 + userBuf.length);\\n' + + ' socket.write(authReq);\\n' + + ' } else { socket.destroy(); resolve({ success: true }); }\\n' + + ' } else if (step === 1) {\\n' + + ' socket.destroy();\\n' + + ' resolve(data[0] === 0x01 && data[1] === 0x00\\n' + + ' ? { success: true }\\n' + + ' : { success: false, error: \\'SOCKS5 authentication failed\\' });\\n' + + ' }\\n' + + ' });\\n' + + ' socket.on(\\'timeout\\', () => { socket.destroy(); resolve({ success: false, error: \\'SOCKS5 handshake timeout\\' }); });\\n' + + ' socket.on(\\'error\\', (err) => resolve({ success: false, error: err.message }));\\n' + + ' });\\n' + + ' } else {\\n' + + ' const socket = await connectToProxy();\\n' + + ' return await new Promise((resolve) => {\\n' + + ' socket.setTimeout(5000);\\n' + + ' const authHeader = config.username && config.password\\n' + + ' ? \\'Proxy-Authorization: Basic \\' + Buffer.from(config.username + \\':\\' + config.password).toString(\\'base64\\') + \\'\\\\r\\\\n\\'\\n' + + ' : \\'\\';\\n' + + ' socket.write(\\'CONNECT httpbin.org:443 HTTP/1.1\\\\r\\\\nHost: httpbin.org:443\\\\r\\\\n\\' + authHeader + \\'\\\\r\\\\n\\');\\n' + + ' let responseData = \\'\\';\\n' + + ' socket.on(\\'data\\', (data) => {\\n' + + ' responseData += data.toString();\\n' + + ' if (responseData.includes(\\'\\\\r\\\\n\\\\r\\\\n\\')) {\\n' + + ' socket.destroy();\\n' + + ' if (responseData.includes(\\'200\\')) resolve({ success: true });\\n' + + ' else if (responseData.includes(\\'407\\')) resolve({ success: false, error: \\'Proxy authentication required\\' });\\n' + + ' else resolve({ success: false, error: \\'Proxy rejected: \\' + (responseData.split(\\'\\\\r\\\\n\\')[0] || \\'Unknown\\') });\\n' + + ' }\\n' + + ' });\\n' + + ' socket.on(\\'timeout\\', () => { socket.destroy(); resolve({ success: false, error: \\'HTTP proxy handshake timeout\\' }); });\\n' + + ' socket.on(\\'error\\', (err) => resolve({ success: false, error: err.message }));\\n' + + ' });\\n' + + ' }\\n' + + ' } catch (e) { return { success: false, error: e.message }; }\\n' + + '});\\n\\n' + 'app.whenReady().then(() => {\\n' + ' createWindow();\\n' + + ' const savedProxy = loadProxyConfig();\\n' + + ' if (savedProxy.enabled && savedProxy.host && savedProxy.port) {\\n' + + ' applyProxy(savedProxy);\\n' + + ' }\\n' + ' globalShortcut.register(\\'CommandOrControl+Shift+I\\', () => {\\n' + ' const focused = BrowserWindow.getFocusedWindow();\\n' + ' if (focused && !focused.isDestroyed()) {\\n' + @@ -399,7 +515,16 @@ jobs: '});'; fs.writeFileSync('electron/main.js', mainJsContent); - + + const preloadJsContent = 'const { contextBridge, ipcRenderer } = require(\\'electron\\');\\n' + + '\\n' + + 'contextBridge.exposeInMainWorld(\\'electronAPI\\', {\\n' + + ' setProxy: (config) => ipcRenderer.invoke(\\'set-proxy\\', config),\\n' + + ' getProxy: () => ipcRenderer.invoke(\\'get-proxy\\'),\\n' + + ' testProxy: (config) => ipcRenderer.invoke(\\'test-proxy\\', config),\\n' + + '});\\n'; + fs.writeFileSync('electron/preload.js', preloadJsContent); + const electronPackageJson = { name: 'github-stars-manager-desktop', version: '1.0.0', diff --git a/server/package-lock.json b/server/package-lock.json index 32a2bd50..5658d03e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,11 +8,13 @@ "name": "github-stars-manager-server", "version": "0.1.0", "dependencies": { + "axios": "^1.7.0", "better-sqlite3": "^11.0.0", "cors": "^2.8.5", "express": "^4.21.0", "helmet": "^7.1.0", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "socks-proxy-agent": "^9.0.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", @@ -1174,6 +1176,41 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -1214,9 +1251,20 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1424,7 +1472,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1575,7 +1622,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1702,7 +1748,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1891,11 +1936,30 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2079,7 +2143,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2132,6 +2195,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -2186,6 +2285,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2739,6 +2847,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -3128,6 +3245,76 @@ "simple-concat": "^1.0.0" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-9.0.0.tgz", + "integrity": "sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==", + "license": "MIT", + "dependencies": { + "agent-base": "8.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz", + "integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/server/package.json b/server/package.json index eb19c2eb..652bf88c 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,9 @@ "better-sqlite3": "^11.0.0", "cors": "^2.8.5", "helmet": "^7.1.0", - "morgan": "^1.10.0" + "morgan": "^1.10.0", + "axios": "^1.7.0", + "socks-proxy-agent": "^9.0.0" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 2921b089..31c5c0c7 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -1,8 +1,28 @@ import { Router } from 'express'; import { getDb } from '../db/connection.js'; -import { decrypt } from '../services/crypto.js'; +import { encrypt, decrypt } from '../services/crypto.js'; import { config } from '../config.js'; -import { proxyRequest } from '../services/proxyService.js'; +import { proxyRequest, ProxyConfig } from '../services/proxyService.js'; + +function getProxyConfig(): ProxyConfig | null { + try { + const db = getDb(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('proxy_config') as { value: string } | undefined; + if (!row?.value) return null; + const parsed = JSON.parse(row.value); + if (parsed && parsed.enabled && parsed.host && parsed.port) { + // Decrypt password if encrypted - fail closed on decrypt error + if (parsed.password_encrypted) { + parsed.password = decrypt(parsed.password_encrypted, config.encryptionKey); + delete parsed.password_encrypted; + } + return parsed as ProxyConfig; + } + return null; + } catch { + return null; + } +} const router = Router(); @@ -77,7 +97,8 @@ router.post('/api/proxy/github/*', async (req, res) => { 'User-Agent': 'GithubStarsManager-Backend', }; - const result = await proxyRequest({ url: targetUrl, method, headers }); + const proxyConfig = getProxyConfig(); + const result = await proxyRequest({ url: targetUrl, method, headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { console.error('GitHub proxy error:', err); @@ -187,12 +208,14 @@ router.post('/api/proxy/ai', async (req, res) => { const timeout = apiType === 'openai-responses' || !!reasoningEffort ? 600000 : 60000; + const proxyConfig = getProxyConfig(); const result = await proxyRequest({ url: targetUrl, method: 'POST', headers, body: effectiveRequestBody, timeout, + proxyConfig, }); res.status(result.status).json(result.data); @@ -242,12 +265,14 @@ router.post('/api/proxy/webdav', async (req, res) => { headers['Content-Type'] = headers['Content-Type'] || 'application/xml'; } + const proxyConfig = getProxyConfig(); const result = await proxyRequest({ url: targetUrl, method, headers, body: requestBody, timeout: 60000, + proxyConfig, }); res.status(result.status).json(result.data); @@ -288,7 +313,8 @@ router.post('/api/proxy/github/search/repositories', async (req, res) => { 'User-Agent': 'GithubStarsManager-Backend', }; - const result = await proxyRequest({ url: targetUrl, method: 'GET', headers }); + const proxyConfig = getProxyConfig(); + const result = await proxyRequest({ url: targetUrl, method: 'GET', headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { console.error('GitHub search repositories proxy error:', err); @@ -327,7 +353,8 @@ router.post('/api/proxy/github/search/users', async (req, res) => { 'User-Agent': 'GithubStarsManager-Backend', }; - const result = await proxyRequest({ url: targetUrl, method: 'GET', headers }); + const proxyConfig = getProxyConfig(); + const result = await proxyRequest({ url: targetUrl, method: 'GET', headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { console.error('GitHub search users proxy error:', err); @@ -335,4 +362,191 @@ router.post('/api/proxy/github/search/users', async (req, res) => { } }); +// GET /api/settings/proxy +router.get('/api/settings/proxy', (_req, res) => { + try { + const db = getDb(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('proxy_config') as { value: string } | undefined; + if (!row?.value) { + res.json({ enabled: false, type: 'http', host: '', port: 7890 }); + return; + } + const parsed = JSON.parse(row.value); + // Mask password - don't expose encrypted value + if (parsed.password_encrypted) { + parsed.hasPassword = true; + } + delete parsed.password_encrypted; + delete parsed.password; + res.json(parsed); + } catch { + res.json({ enabled: false, type: 'http', host: '', port: 7890 }); + } +}); + +// PUT /api/settings/proxy +router.put('/api/settings/proxy', (req, res) => { + try { + const db = getDb(); + const { enabled, type, host, port, username, password } = req.body; + const passwordProvided = 'password' in req.body; + + const configToStore: Record = { enabled, type, host, port, username }; + if (passwordProvided && password) { + // New password provided - encrypt and store + configToStore.password_encrypted = encrypt(password, config.encryptionKey); + } else if (passwordProvided && !password) { + // Explicitly empty password - clear stored secret + // No password_encrypted field = no password + } else { + // Password field omitted - preserve existing encrypted password + const existing = db.prepare('SELECT value FROM settings WHERE key = ?').get('proxy_config') as { value: string } | undefined; + if (existing?.value) { + try { + const parsed = JSON.parse(existing.value); + if (parsed.password_encrypted) { + configToStore.password_encrypted = parsed.password_encrypted; + } + } catch { /* ignore */ } + } + } + + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run('proxy_config', JSON.stringify(configToStore)); + + res.json({ success: true }); + } catch (err) { + console.error('Failed to save proxy config:', err); + res.status(500).json({ error: 'Failed to save proxy config' }); + } +}); + +// POST /api/settings/proxy/test +router.post('/api/settings/proxy/test', async (req, res) => { + try { + const { host, port, type, username, password } = req.body; + if (!host || !port) { + res.json({ success: false, error: 'Host and port are required' }); + return; + } + + const net = await import('net'); + type NetSocket = import('net').Socket; + + const connectToProxy = (): Promise => + new Promise((resolve, reject) => { + const socket = new net.Socket(); + socket.setTimeout(5000); + socket.on('connect', () => resolve(socket)); + socket.on('timeout', () => { socket.destroy(); reject(new Error('Connection timeout')); }); + socket.on('error', (err: Error) => reject(err)); + socket.connect(port, host); + }); + + let result: { success: boolean; error?: string }; + + if (type === 'socks5') { + // SOCKS5 protocol handshake test + try { + const socket = await connectToProxy(); + result = await new Promise((resolve) => { + // Step 1: Send SOCKS5 greeting (version 5, 1 method, no-auth) + const greeting = username + ? Buffer.from([0x05, 0x02, 0x00, 0x02]) // no-auth + username/password + : Buffer.from([0x05, 0x01, 0x00]); // no-auth only + + socket.setTimeout(5000); + socket.write(greeting); + + let step = 0; + socket.on('data', (data: Buffer) => { + if (step === 0) { + // Step 2: Server selects auth method + if (data[0] !== 0x05) { + socket.destroy(); + resolve({ success: false, error: `Invalid SOCKS5 version: ${data[0]}` }); + return; + } + if (data[1] === 0xFF) { + socket.destroy(); + resolve({ success: false, error: 'SOCKS5 server: no acceptable auth method' }); + return; + } + if (data[1] === 0x02 && username && password) { + // Username/password auth + step = 1; + const userBuf = Buffer.from(username, 'utf8'); + const passBuf = Buffer.from(password, 'utf8'); + const authReq = Buffer.alloc(3 + userBuf.length + passBuf.length); + authReq[0] = 0x01; // auth version + authReq[1] = userBuf.length; + userBuf.copy(authReq, 2); + authReq[2 + userBuf.length] = passBuf.length; + passBuf.copy(authReq, 3 + userBuf.length); + socket.write(authReq); + } else { + // No-auth accepted, test passed + socket.destroy(); + resolve({ success: true }); + } + } else if (step === 1) { + // Step 3: Auth response + socket.destroy(); + if (data[0] === 0x01 && data[1] === 0x00) { + resolve({ success: true }); + } else { + resolve({ success: false, error: 'SOCKS5 authentication failed' }); + } + } + }); + + socket.on('timeout', () => { socket.destroy(); resolve({ success: false, error: 'SOCKS5 handshake timeout' }); }); + socket.on('error', (err: Error) => { resolve({ success: false, error: err.message }); }); + }); + } catch (err) { + result = { success: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } + } else { + // HTTP proxy: send CONNECT request to verify it's a working proxy + try { + const socket = await connectToProxy(); + result = await new Promise((resolve) => { + socket.setTimeout(5000); + const authHeader = username && password + ? `Proxy-Authorization: Basic ${Buffer.from(`${username}:${password}`).toString('base64')}\r\n` + : ''; + const connectReq = `CONNECT httpbin.org:443 HTTP/1.1\r\nHost: httpbin.org:443\r\n${authHeader}\r\n`; + socket.write(connectReq); + + let responseData = ''; + socket.on('data', (data: Buffer) => { + responseData += data.toString(); + // Wait for end of HTTP headers + if (responseData.includes('\r\n\r\n')) { + socket.destroy(); + if (responseData.includes('200')) { + resolve({ success: true }); + } else if (responseData.includes('407')) { + resolve({ success: false, error: 'Proxy authentication required' }); + } else { + const statusLine = responseData.split('\r\n')[0] || 'Unknown'; + resolve({ success: false, error: `Proxy rejected CONNECT: ${statusLine}` }); + } + } + }); + + socket.on('timeout', () => { socket.destroy(); resolve({ success: false, error: 'HTTP proxy handshake timeout' }); }); + socket.on('error', (err: Error) => { resolve({ success: false, error: err.message }); }); + }); + } catch (err) { + result = { success: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } + } + + res.json(result); + } catch (err) { + res.json({ success: false, error: err instanceof Error ? err.message : 'Unknown error' }); + } +}); + export default router; diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index 8a1a17e8..63ac9b03 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -1,9 +1,21 @@ +import axios, { AxiosRequestConfig } from 'axios'; + +export interface ProxyConfig { + enabled: boolean; + type: 'http' | 'socks5'; + host: string; + port: number; + username?: string; + password?: string; +} + export interface ProxyRequestOptions { url: string; method: string; headers?: Record; body?: string | object; timeout?: number; + proxyConfig?: ProxyConfig | null; } export interface ProxyResponse { @@ -57,60 +69,106 @@ function validateUrl(rawUrl: string): void { } export async function proxyRequest(options: ProxyRequestOptions): Promise { - const { url, method, headers = {}, body, timeout = 30000 } = options; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); + const { url, method, headers = {}, body, timeout = 30000, proxyConfig } = options; try { validateUrl(url); console.log(`[Proxy] ${method} ${redactUrl(url)}`); - const fetchOptions: RequestInit = { - method, + const axiosConfig: AxiosRequestConfig = { + url, + method: method.toLowerCase() as AxiosRequestConfig['method'], headers, - signal: controller.signal, + timeout, + validateStatus: () => true, // 不抛出 HTTP 错误状态码 }; if (body && method !== 'GET' && method !== 'HEAD') { - fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); + axiosConfig.data = body; const hasContentType = Object.keys(headers).some( k => k.toLowerCase() === 'content-type' ); - if (!hasContentType) { - (fetchOptions.headers as Record)['Content-Type'] = 'application/json'; + if (!hasContentType && typeof body === 'object') { + axiosConfig.headers = { ...axiosConfig.headers, 'Content-Type': 'application/json' }; + } + } + + // 配置代理 + if (proxyConfig?.enabled && proxyConfig.host && proxyConfig.port) { + if (proxyConfig.type === 'socks5') { + // SOCKS5: axios 不原生支持,使用 socks-proxy-agent + const { SocksProxyAgent } = await import('socks-proxy-agent'); + const socksUrl = `socks5://${proxyConfig.host}:${proxyConfig.port}`; + const agent = new SocksProxyAgent(socksUrl); + axiosConfig.httpAgent = agent; + axiosConfig.httpsAgent = agent; + axiosConfig.proxy = false; // 禁用 axios 内置代理,使用自定义 agent + } else { + // HTTP/HTTPS 代理 + axiosConfig.proxy = { + protocol: 'http', + host: proxyConfig.host, + port: proxyConfig.port, + }; + if (proxyConfig.username && proxyConfig.password) { + axiosConfig.proxy.auth = { + username: proxyConfig.username, + password: proxyConfig.password, + }; + } } + } else { + // 显式禁用代理,防止 axios 回退到环境变量 HTTP_PROXY/HTTPS_PROXY + axiosConfig.proxy = false; } - const response = await fetch(url, fetchOptions); + const response = await axios(axiosConfig); console.log(`[Proxy] ${method} ${redactUrl(url)} -> ${response.status}`); const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); + if (response.headers) { + for (const [key, value] of Object.entries(response.headers)) { + responseHeaders[key] = String(value); + } + } let data: unknown; - const contentType = response.headers.get('content-type') || ''; - const text = await response.text(); - if (contentType.includes('application/json') && text.length > 0) { + const contentType = String(response.headers['content-type'] || ''); + if (contentType.includes('application/json') && typeof response.data === 'object') { + data = response.data; + } else if (typeof response.data === 'string') { try { - data = JSON.parse(text); + data = JSON.parse(response.data); } catch { - data = text; + data = response.data; } } else { - data = text; + data = response.data; } return { status: response.status, headers: responseHeaders, data }; } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return { status: 504, headers: {}, data: { error: 'Gateway Timeout', code: 'GATEWAY_TIMEOUT' } }; + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) { + return { status: 504, headers: {}, data: { error: 'Gateway Timeout', code: 'GATEWAY_TIMEOUT' } }; + } + if (error.code === 'ECONNREFUSED') { + return { status: 502, headers: {}, data: { error: 'Proxy connection refused', code: 'PROXY_CONNECTION_REFUSED', details: error.message } }; + } + if (error.code === 'ETIMEDOUT') { + return { status: 504, headers: {}, data: { error: 'Proxy connection timeout', code: 'PROXY_TIMEOUT', details: error.message } }; + } + if (error.response) { + // 请求已发出,服务器返回了错误状态码 + return { + status: error.response.status, + headers: {}, + data: error.response.data || { error: 'Upstream error' } + }; + } } console.error(`[Proxy] Error: ${error instanceof Error ? error.message : 'Unknown error'}`); return { status: 502, headers: {}, data: { error: 'Bad Gateway', code: 'BAD_GATEWAY', details: error instanceof Error ? error.message : 'Unknown error' } }; - } finally { - clearTimeout(timeoutId); } } diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 6bd98102..3747c446 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -9,8 +9,11 @@ import { Package, X, Trash2, + Wifi, } from 'lucide-react'; import { useAppStore } from '../store/useAppStore'; +import { isElectron } from '../services/electronProxy'; +import { backend } from '../services/backendAdapter'; import { GeneralPanel, AIConfigPanel, @@ -19,9 +22,10 @@ import { BackendPanel, CategoryPanel, DataManagementPanel, + NetworkPanel, } from './settings'; -type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'data'; +type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'data' | 'network'; interface SettingsTabItem { id: SettingsTab; @@ -282,6 +286,11 @@ export const SettingsPanel: React.FC = ({ label: t('数据管理', 'Data Management'), icon: , }, + ...((isElectron() || backend.isAvailable) ? [{ + id: 'network' as SettingsTab, + label: t('网络设置', 'Network'), + icon: , + }] : []), ]; const renderTabContent = () => { @@ -301,6 +310,8 @@ export const SettingsPanel: React.FC = ({ return ; case 'data': return ; + case 'network': + return ; default: return null; } diff --git a/src/components/settings/NetworkPanel.tsx b/src/components/settings/NetworkPanel.tsx new file mode 100644 index 00000000..5fe2b41d --- /dev/null +++ b/src/components/settings/NetworkPanel.tsx @@ -0,0 +1,300 @@ +import React, { useState, useEffect } from 'react'; +import { Wifi, Eye, EyeOff, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { useAppStore } from '../../store/useAppStore'; +import { backend } from '../../services/backendAdapter'; +import { isElectron, electronProxy } from '../../services/electronProxy'; +import type { ProxyConfig, ProxyType } from '../../types'; + +interface NetworkPanelProps { + t: (zh: string, en: string) => string; +} + +export const NetworkPanel: React.FC = ({ t }) => { + const { proxyConfig, setProxyConfig, backendApiSecret } = useAppStore(); + + const [form, setForm] = useState(proxyConfig); + const [showPassword, setShowPassword] = useState(false); + const [showAuth, setShowAuth] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null); + const [saving, setSaving] = useState(false); + + // Sync form when store changes externally + useEffect(() => { + setForm(proxyConfig); + if (proxyConfig.username || proxyConfig.password) { + setShowAuth(true); + } + }, [proxyConfig]); + + const canUseProxy = isElectron() || backend.isAvailable; + + if (!canUseProxy) { + return null; + } + + const isFormValid = !form.enabled || (form.host.trim() && form.port >= 1 && form.port <= 65535); + + const handleSave = async () => { + if (!isFormValid) return; + + setSaving(true); + setTestResult(null); + const previousConfig = proxyConfig; + try { + // Sync to Electron first (if applicable) + if (isElectron()) { + await electronProxy.setProxy(form); + } + + // Sync to backend (if applicable) + if (backend.isAvailable) { + const authHeaders: Record = { 'Content-Type': 'application/json' }; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; + } + const resp = await fetch('/api/settings/proxy', { + method: 'PUT', + headers: authHeaders, + body: JSON.stringify(form), + }); + if (!resp.ok) { + throw new Error(`Backend returned ${resp.status}`); + } + } + + // Only persist locally after remote sync succeeds + setProxyConfig(form); + } catch (e) { + // Rollback: restore Electron proxy to previous state + if (isElectron()) { + try { await electronProxy.setProxy(previousConfig); } catch { /* best effort */ } + } + setTestResult({ success: false, error: e instanceof Error ? e.message : t('保存失败', 'Save failed') }); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + if (isElectron()) { + const result = await electronProxy.testProxy(form); + setTestResult(result); + } else if (backend.isAvailable) { + const authHeaders: Record = { 'Content-Type': 'application/json' }; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; + } + const resp = await fetch('/api/settings/proxy/test', { + method: 'POST', + headers: authHeaders, + body: JSON.stringify(form), + }); + const data = await resp.json(); + setTestResult(data); + } + } catch (e) { + setTestResult({ success: false, error: e instanceof Error ? e.message : 'Unknown error' }); + } finally { + setTesting(false); + } + }; + + const hasChanges = JSON.stringify(form) !== JSON.stringify(proxyConfig); + + return ( +
+
+
+ +

+ {t('网络代理', 'Network Proxy')} +

+
+ +
+ + {form.enabled && ( +
+ {/* Proxy Type */} +
+ +
+ {(['http', 'socks5'] as ProxyType[]).map((type) => ( + + ))} +
+
+ + {/* Host and Port */} +
+
+ + setForm({ ...form, host: e.target.value })} + placeholder="127.0.0.1" + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ + setForm({ ...form, port: parseInt(e.target.value) || 0 })} + placeholder="7890" + min={1} + max={65535} + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ + {/* Authentication (collapsible) */} +
+ + + {showAuth && ( +
+
+ + setForm({ ...form, username: e.target.value || undefined })} + placeholder={t('可选', 'Optional')} + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ +
+ setForm({ ...form, password: e.target.value || undefined })} + placeholder={t('可选', 'Optional')} + className="w-full px-3 py-2 pr-10 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> + +
+
+
+ )} +
+ + {/* Actions */} +
+ + + +
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + + {testResult.success + ? t('代理连接成功', 'Proxy connection successful') + : testResult.error || t('代理连接失败', 'Proxy connection failed')} + +
+ )} +
+ )} +
+ ); +}; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index d7e71ff4..c975730e 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -5,3 +5,4 @@ export { BackendPanel } from './BackendPanel'; export { CategoryPanel } from './CategoryPanel'; export { GeneralPanel } from './GeneralPanel'; export { DataManagementPanel } from './DataManagementPanel'; +export { NetworkPanel } from './NetworkPanel'; diff --git a/src/services/electronProxy.ts b/src/services/electronProxy.ts new file mode 100644 index 00000000..3421691d --- /dev/null +++ b/src/services/electronProxy.ts @@ -0,0 +1,36 @@ +import type { ProxyConfig } from '../types'; + +interface ElectronAPI { + setProxy: (config: ProxyConfig) => Promise<{ success: boolean }>; + getProxy: () => Promise; + testProxy: (config: ProxyConfig) => Promise<{ success: boolean; error?: string }>; +} + +declare global { + interface Window { + electronAPI?: ElectronAPI; + } +} + +export const isElectron = (): boolean => { + return typeof window !== 'undefined' && !!window.electronAPI; +}; + +export const electronProxy = { + async setProxy(config: ProxyConfig): Promise { + if (window.electronAPI) { + await window.electronAPI.setProxy(config); + } + }, + + async getProxy(): Promise { + return window.electronAPI?.getProxy() ?? null; + }, + + async testProxy(config: ProxyConfig): Promise<{ success: boolean; error?: string }> { + if (!window.electronAPI) { + return { success: false, error: 'Not running in Electron' }; + } + return window.electronAPI.testProxy(config); + }, +}; diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 618c552f..dbb0925c 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -5,16 +5,17 @@ import { Repository, Release, ForkRepo, - AIConfig, - WebDAVConfig, - SearchFilters, - GitHubUser, - Category, - AssetFilter, - UpdateNotification, - AnalysisProgress, - DiscoveryChannel, - DiscoveryChannelId, + AIConfig, + WebDAVConfig, + ProxyConfig, + SearchFilters, + GitHubUser, + Category, + AssetFilter, + UpdateNotification, + AnalysisProgress, + DiscoveryChannel, + DiscoveryChannelId, DiscoveryRepo, DiscoveryPlatform, ProgrammingLanguage, @@ -167,6 +168,9 @@ interface AppActions { // Backend actions setBackendApiSecret: (secret: string | null) => void; + // Proxy actions + setProxyConfig: (updates: Partial) => void; + // Release Timeline View actions setReleaseViewMode: (mode: 'timeline' | 'repository') => void; setReleaseSelectedFilters: (filters: string[]) => void; @@ -271,6 +275,7 @@ type PersistedAppState = Partial< | 'discoveryLanguage' | 'discoverySortBy' | 'discoverySortOrder' + | 'proxyConfig' | 'subscriptionRepos' | 'subscriptionLastRefresh' | 'subscriptionIsLoading' @@ -523,6 +528,24 @@ const normalizePersistedState = ( defaultSubscriptionChannels.filter(dch => !persisted.some((ch: unknown) => (ch as Record).id === dch.id)) ); })(), + proxyConfig: (() => { + const p = (safePersisted as Record).proxyConfig; + if (p && typeof p === 'object') { + const obj = p as Record; + const validType = obj.type === 'http' || obj.type === 'socks5' ? obj.type : 'http'; + const validHost = typeof obj.host === 'string' ? obj.host : ''; + const validPort = typeof obj.port === 'number' && Number.isFinite(obj.port) ? obj.port : 7890; + return { + enabled: typeof obj.enabled === 'boolean' ? obj.enabled : false, + type: validType as import('../types').ProxyType, + host: validHost, + port: validPort, + username: typeof obj.username === 'string' ? obj.username : undefined, + // password 不从持久化恢复,仅在内存中 + }; + } + return { enabled: false, type: 'http' as const, host: '', port: 7890 }; + })(), }; }; @@ -710,6 +733,7 @@ export const useAppStore = create()( updateNotification: null, analysisProgress: { current: 0, total: 0 }, backendApiSecret: readSessionBackendSecret(), + proxyConfig: { enabled: false, type: 'http', host: '', port: 7890 }, isSidebarCollapsed: false, readmeModalOpen: false, releaseViewMode: 'timeline', @@ -1234,6 +1258,9 @@ export const useAppStore = create()( writeSessionBackendSecret(backendApiSecret); set({ backendApiSecret }); }, + setProxyConfig: (updates) => set((state) => ({ + proxyConfig: { ...state.proxyConfig, ...updates } + })), // Release Timeline View actions setReleaseViewMode: (releaseViewMode) => set({ releaseViewMode }), @@ -1389,7 +1416,7 @@ export const useAppStore = create()( }), { name: 'github-stars-manager', - version: 5, + version: 6, storage: debouncedPersistStorage, partialize: (state) => ({ // 持久化用户信息和认证状态 @@ -1474,6 +1501,15 @@ export const useAppStore = create()( discoverySortBy: state.discoverySortBy, discoverySortOrder: state.discoverySortOrder, discoverySelectedTopic: state.discoverySelectedTopic, + // 持久化代理配置,但排除密码(安全考虑) + proxyConfig: { + enabled: state.proxyConfig.enabled, + type: state.proxyConfig.type, + host: state.proxyConfig.host, + port: state.proxyConfig.port, + username: state.proxyConfig.username, + // password 不持久化,仅保留在内存中 + }, }), migrate: (persistedState) => { // 版本升级适配处理 @@ -1595,6 +1631,11 @@ export const useAppStore = create()( }; } + // v5→v6: 初始化 proxyConfig + if (state && !(state as Record).proxyConfig) { + (state as Record).proxyConfig = { enabled: false, type: 'http', host: '', port: 7890 }; + } + return state as PersistedAppState; }, merge: (persistedState, currentState) => { diff --git a/src/types/index.ts b/src/types/index.ts index 0733a6eb..c511ce35 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -158,6 +158,17 @@ export interface WebDAVConfig { passwordStatus?: SecretStatus; } +export type ProxyType = 'http' | 'socks5'; + +export interface ProxyConfig { + enabled: boolean; + type: ProxyType; + host: string; + port: number; + username?: string; + password?: string; +} + export interface SearchFilters { query: string; tags: string[]; @@ -248,6 +259,9 @@ export interface AppState { // Backend backendApiSecret: string | null; + // Network Proxy + proxyConfig: ProxyConfig; + // Fork Timeline View forks: ForkRepo[]; readForks: Set;