diff --git a/proxy.ts b/proxy.ts index 2d0c7277c..31fb278f1 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,9 +1,9 @@ -import process from 'node:process'; +/* global RequestInit */ import { Buffer } from 'buffer'; import { FetchMode, ServerSetting } from './src/types/types'; import { Connect } from 'vite'; import httpProxy from 'http-proxy'; -import { exec } from 'child_process'; +import { spawn } from 'child_process'; import { brotliDecompressSync, gunzipSync, zstdDecompressSync } from 'zlib'; const proxy = httpProxy.createProxyServer({}); @@ -11,13 +11,15 @@ const proxy = httpProxy.createProxyServer({}); const settings: ServerSetting = { CLIENT_HOST: 'http://localhost:3000', fetchMode: FetchMode.PROXY, + cookies: '', + siteCookies: {}, + usePerSiteCookies: false, disAllowedRequestHeaders: [ 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-fetch-site', 'origin', - 'sec-fetch-site', 'sec-fetch-dest', 'pragma', ], @@ -62,6 +64,19 @@ const proxySettingMiddleware: Connect.NextHandleFunction = (req, res) => { }); }; +function getCookiesForHost(hostname: string): string | undefined { + if (settings.usePerSiteCookies && settings.siteCookies) { + // exact match + if (settings.siteCookies[hostname]) return settings.siteCookies[hostname]; + // parent domain match: key "source.com" matches "www.source.com", "api.source.com" + for (const [site, cookie] of Object.entries(settings.siteCookies)) { + if (hostname.endsWith('.' + site)) return cookie; + } + return undefined; // per-site mode: no cookie for unmatched hosts + } + return settings.cookies; // global mode: same cookie for all requests +} + const proxyHandlerMiddle: Connect.NextHandleFunction = (req, res) => { const rawUrl = 'https:' + req.url; if (req.headers['access-control-request-method']) { @@ -97,7 +112,6 @@ const proxyHandlerMiddle: Connect.NextHandleFunction = (req, res) => { } } req.headers['sec-fetch-mode'] = 'cors'; - if (settings.cookies) req.headers['cookie'] = settings.cookies; if (!settings.useUserAgent) delete req.headers['user-agent']; req.headers.host = _url.host; req.url = _url.toString(); @@ -114,82 +128,207 @@ const proxyHandlerMiddle: Connect.NextHandleFunction = (req, res) => { } }; -const proxyRequest: Connect.SimpleHandleFunction = (req, res) => { - const _url = new URL(req.url || ''); +function curlRequest( + url: string, + method: string, + req: Connect.IncomingMessage, + bodyBuffer: Buffer, + redirectCount: number, + res: Connect.ServerResponse, +) { + if (redirectCount >= 5) { + res.statusCode = 508; + res.end('Too many redirects'); + return; + } + + const _url = new URL(url); + const args = ['-X', method, '-s', '-D', '-', _url.href]; + + if (settings.useUserAgent && req.headers['user-agent']) + args.push('-H', 'User-Agent: ' + req.headers['user-agent']); + const hostCookie = getCookiesForHost(_url.hostname); + if (hostCookie) args.push('-H', 'Cookie: ' + hostCookie); + if (req.headers.origin2) args.push('-H', 'Origin: ' + req.headers.origin2); + if (req.headers['content-type'] && bodyBuffer.length > 0) + args.push('-H', 'Content-Type: ' + req.headers['content-type']); + + if (bodyBuffer.length > 0) args.push('--data-binary', '@-'); + console.log('\x1b[36m', '----------------'); console.log( - `Making proxy request - at ${new Date().toLocaleTimeString()} - url: ${_url.href} - headers:`, + 'Making CURL request - at ' + + new Date().toLocaleTimeString() + + '\n url: ' + + _url.href + + '\n headers:', ); - Object.entries(req.headers).forEach(([name, value]) => { - console.log('\t', '\x1b[32m', name + ':', '\x1b[37m', value); + args.forEach((a, i) => { + if (args[i - 1] === '-H') console.log('\t', '\x1b[32m', a, '\x1b[37m'); }); console.log('\x1b[36m', '----------------'); - if (settings.fetchMode === FetchMode.CURL) { - let curl = `curl -L '${_url.href}'`; - if (settings.useUserAgent) - curl += ` -H 'User-Agent: ${req.headers['user-agent']}'`; - if (settings.cookies) curl += ` -H 'Cookie: ${settings.cookies}'`; - if (req.headers.origin2) curl += ` -H 'Origin: ${req.headers.origin2}'`; - - const isWindows = process.platform === 'win32'; - const options = isWindows - ? { - shell: - process.env.BASH_LOCATION || - process.env.ProgramFiles + '\\git\\usr\\bin\\bash.exe', - } - : {}; + const child = spawn('curl', args); - exec(curl, options, (error, stdout) => { - if (error) { + if (bodyBuffer.length > 0) { + child.stdin.write(bodyBuffer); + child.stdin.end(); + } + + const stdoutChunks: Buffer[] = []; + child.stdout.on('data', chunk => stdoutChunks.push(Buffer.from(chunk))); + + let stderr = ''; + child.stderr.on('data', chunk => (stderr += chunk)); + + child.on('close', code => { + if (code !== 0) { + console.error(stderr); + if (!res.headersSent) { res.statusCode = 500; - res.write(`exec error: ${error}`); + res.write('curl error code: ' + code + '\n' + stderr); res.end(); - return; } + return; + } + + const output = Buffer.concat(stdoutChunks).toString('utf-8'); + const headerEnd = output.indexOf('\r\n\r\n'); + if (headerEnd === -1) { res.statusCode = 200; - res.write(stdout); + res.write(output); res.end(); - }); - } else if (settings.fetchMode === FetchMode.NODE_FETCH) { - const headers = new Headers(); - if (settings.useUserAgent) - headers.append('user-agent', req.headers['user-agent'] as string); - if (settings.cookies) headers.append('cookie', settings.cookies); - if (req.headers.origin2) - headers.append('origin', req.headers.origin2 as string); - - fetch(_url.href, { headers }) - .then(async res2 => { - res.statusCode = res2.status; - res2.headers.forEach((val, key) => { - if (!settings.disAllowResponseHeaders.includes(key)) { - res.setHeader(key, val); + return; + } + + const headerBlock = output.slice(0, headerEnd); + const body = output.slice(headerEnd + 4); + + const statusMatch = headerBlock.match(/^HTTP\/[\d.]+\s+(\d+)/m); + const statusCode = statusMatch ? parseInt(statusMatch[1]) : 200; + + if ([301, 302, 303, 307, 308].includes(statusCode)) { + const locMatch = headerBlock.match(/^location:\s*(.+)$/im); + if (locMatch) { + try { + const redirectUrl = new URL(locMatch[1].trim(), _url.href); + let nextMethod = method; + let nextBody = bodyBuffer; + if ([301, 302, 303].includes(statusCode)) { + nextMethod = 'GET'; + nextBody = Buffer.alloc(0); } - }); - res.write(await res2.text()); - res.end(); - }) - .catch(err => { - console.error(err); - res.statusCode = 500; - res.end(); - }); - } else if (settings.fetchMode === FetchMode.PROXY) { + curlRequest( + redirectUrl.href, + nextMethod, + req, + nextBody, + redirectCount + 1, + res, + ); + return; + } catch { + console.error('Redirect URL parse error:', locMatch[1]); + } + } + } + + res.statusCode = statusCode; + + const headerLines = headerBlock.split('\r\n').slice(1); + for (const line of headerLines) { + const ci = line.indexOf(':'); + if (ci === -1) continue; + const key = line.slice(0, ci).trim().toLowerCase(); + const val = line.slice(ci + 1).trim(); + if (!settings.disAllowResponseHeaders.includes(key)) { + res.setHeader(key, val); + } + } + + res.write(body); + res.end(); + }); + + res.on('close', () => { + if (!child.killed) child.kill(); + }); +} + +const proxyRequest: Connect.SimpleHandleFunction = (req, res) => { + const _url = new URL(req.url || ''); + console.log('\x1b[36m', '----------------'); + console.log( + `Making proxy request - at ${new Date().toLocaleTimeString()} + url: ${_url.href} + headers:`, + ); + Object.entries(req.headers).forEach(([name, value]) => { + console.log('\t', '\x1b[32m', name + ':', '\x1b[37m', value); + }); + console.log('\x1b[36m', '----------------'); + + if (settings.fetchMode === FetchMode.PROXY) { + const hostCookie = getCookiesForHost(_url.hostname); + if (hostCookie) req.headers['cookie'] = hostCookie; + else delete req.headers['cookie']; proxy.web( req, res, - { target: _url.origin, selfHandleResponse: true, followRedirects: true }, + { target: _url.origin, selfHandleResponse: true }, err => { console.error('Proxy target error:', err); res.statusCode = 500; res.end(); }, ); + return; } + + const method = req.method || 'GET'; + const chunks: Buffer[] = []; + + req.on('data', chunk => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + const bodyBuffer = Buffer.concat(chunks); + + if (settings.fetchMode === FetchMode.CURL) { + curlRequest(_url.href, method, req, bodyBuffer, 0, res); + } else if (settings.fetchMode === FetchMode.NODE_FETCH) { + const headers = new Headers(); + + if (settings.useUserAgent && req.headers['user-agent']) + headers.append('user-agent', req.headers['user-agent'] as string); + const hostCookie = getCookiesForHost(_url.hostname); + if (hostCookie) headers.append('cookie', hostCookie); + if (req.headers.origin2) + headers.append('origin', req.headers.origin2 as string); + if (req.headers['content-type']) + headers.append('content-type', req.headers['content-type'] as string); + + const fetchOptions: RequestInit = { method, headers }; + if (method !== 'GET' && method !== 'HEAD' && bodyBuffer.length > 0) { + fetchOptions.body = bodyBuffer; + } + + fetch(_url.href, fetchOptions) + .then(async res2 => { + res.statusCode = res2.status; + res2.headers.forEach((val, key) => { + if (!settings.disAllowResponseHeaders.includes(key)) { + res.setHeader(key, val); + } + }); + res.write(await res2.text()); + res.end(); + }) + .catch(err => { + console.error(err); + res.statusCode = 500; + res.end(); + }); + } + }); }; proxy.on('proxyRes', function (proxyRes, req, res) { @@ -224,6 +363,7 @@ proxy.on('proxyRes', function (proxyRes, req, res) { } req.removeAllListeners(); + proxyRes.destroy(); proxyRequest(req, res); return; } catch (err) { @@ -259,7 +399,11 @@ proxy.on('proxyRes', function (proxyRes, req, res) { } else if (contentEncoding.includes('gzip')) { decompressedBuffer = gunzipSync(compressedBuffer); } else if (contentEncoding.includes('zstd')) { - decompressedBuffer = zstdDecompressSync(compressedBuffer); + try { + decompressedBuffer = zstdDecompressSync(compressedBuffer); + } catch { + decompressedBuffer = compressedBuffer; + } } else { decompressedBuffer = compressedBuffer; } diff --git a/src/components/settings.tsx b/src/components/settings.tsx index c90e28918..52a377a16 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useRef } from 'react'; import { CheckedState } from '@radix-ui/react-checkbox'; -import { Check } from 'lucide-react'; +import { Check, X } from 'lucide-react'; + +import { useAppStore } from '@/store'; import { Card } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; @@ -23,15 +25,25 @@ const FETCH_MODES = { }; const SettingsSection = React.memo(function SettingsSection() { + const plugin = useAppStore(state => state.plugin); const [settings, setSettings] = useState({ cookies: '', fetchMode: FetchMode.PROXY, useUserAgent: true as CheckedState, + siteCookies: {} as Record, + usePerSiteCookies: false as CheckedState, }); const [status, setStatus] = useState<'idle' | 'loading' | 'saved'>('idle'); + const [showAllSites, setShowAllSites] = useState(false); + const [addSiteHost, setAddSiteHost] = useState(''); + const [addSiteCookie, setAddSiteCookie] = useState(''); + const [addSiteError, setAddSiteError] = useState(''); const init = useRef(false); const lastSaved = useRef(null); const debouncedCookies = useDebounce(settings.cookies, 500); + const currentSiteHostname = plugin?.site + ? new URL(plugin.site).hostname.replace(/^www\./, '') + : undefined; useEffect(() => { fetch('settings') @@ -41,6 +53,8 @@ const SettingsSection = React.memo(function SettingsSection() { cookies: data.cookies || '', fetchMode: data.fetchMode ?? FetchMode.PROXY, useUserAgent: data.useUserAgent ?? true, + siteCookies: data.siteCookies || {}, + usePerSiteCookies: data.usePerSiteCookies ?? false, }; setSettings(loaded); lastSaved.current = loaded; @@ -51,7 +65,18 @@ const SettingsSection = React.memo(function SettingsSection() { useEffect(() => { if (!init.current || debouncedCookies !== settings.cookies) return; - const current = { ...settings, cookies: debouncedCookies }; + + // strip empty keys from siteCookies before saving + const cleanSiteCookies = { ...settings.siteCookies }; + for (const key in cleanSiteCookies) { + if (!cleanSiteCookies[key]) delete cleanSiteCookies[key]; + } + + const current = { + ...settings, + cookies: debouncedCookies, + siteCookies: cleanSiteCookies, + }; if (JSON.stringify(lastSaved.current) === JSON.stringify(current)) return; @@ -72,6 +97,8 @@ const SettingsSection = React.memo(function SettingsSection() { settings.fetchMode, settings.useUserAgent, settings.cookies, + settings.siteCookies, + settings.usePerSiteCookies, ]); const update = ( @@ -129,24 +156,198 @@ const SettingsSection = React.memo(function SettingsSection() { -
+
+ update('usePerSiteCookies', v)} + /> - update('cookies', e.target.value.trim())} - placeholder="Enter cookies (optional)..." - className="font-mono text-xs" - /> -

- Additional cookies to send with requests (optional) -

+ + {!settings.usePerSiteCookies ? ( +
+ + update('cookies', e.target.value.trim())} + placeholder="Enter cookies (optional)..." + className="font-mono text-xs" + /> +

+ Applied to all requests +

+
+ ) : ( +
+ {currentSiteHostname ? ( +
+ +
+ { + const val = e.target.value.trim(); + setSettings(prev => ({ + ...prev, + siteCookies: { + ...prev.siteCookies, + [currentSiteHostname]: val, + }, + })); + }} + placeholder="Enter cookie (optional)..." + className="font-mono text-xs" + /> + {settings.siteCookies[currentSiteHostname] && ( + + )} +
+

+ Sent instead of global cookies for requests to this + hostname +

+
+ ) : ( +

+ Select a plugin to configure per-site cookies for its + hostname. +

+ )} + +
+
+
+ + add hosts + +
+
+
+ setAddSiteHost(e.target.value)} + placeholder="https://..." + className="font-mono text-xs flex-1" + /> + setAddSiteCookie(e.target.value)} + placeholder="cookie value" + className="font-mono text-xs flex-[2]" + /> + +
+
+ {addSiteError && ( +

+ {addSiteError} +

+ )} + + {Object.keys(settings.siteCookies).length > 0 && ( +
+ + {showAllSites && ( +
+ {Object.entries(settings.siteCookies) + .filter(([, v]) => v) + .map(([site, cookie]) => ( +
+ + {site} + + + {cookie} + + +
+ ))} +
+ )} +
+ )} +
+ )}
diff --git a/src/types/types.ts b/src/types/types.ts index 2fc7ca7e8..addb34fd0 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -12,6 +12,8 @@ export type ServerSetting = { CLIENT_HOST: string; fetchMode: FetchMode; cookies?: string; + siteCookies?: Record; + usePerSiteCookies?: boolean; disAllowedRequestHeaders: string[]; disAllowResponseHeaders: string[]; useUserAgent: boolean;