diff --git a/src/auth.js b/src/auth.js index 1188445..162462c 100644 --- a/src/auth.js +++ b/src/auth.js @@ -9,7 +9,7 @@ */ import { randomUUID } from 'crypto'; -import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from 'fs'; import { config, log } from './config.js'; import { getEffectiveProxy } from './dashboard/proxy-config.js'; import { getTierModels, getModelKeysByEnum, MODELS } from './models.js'; @@ -54,9 +54,12 @@ function saveAccounts() { userStatus: a.userStatus || null, userStatusLastFetched: a.userStatusLastFetched || 0, })); - writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2)); + const tempFile = ACCOUNTS_FILE + '.tmp'; + writeFileSync(tempFile, JSON.stringify(data, null, 2)); + renameSync(tempFile, ACCOUNTS_FILE); } catch (e) { log.error('Failed to save accounts:', e.message); + try { unlinkSync(ACCOUNTS_FILE + '.tmp'); } catch {} } } @@ -552,7 +555,6 @@ export function getAccountList() { lastUsed: a.lastUsed ? new Date(a.lastUsed).toISOString() : null, addedAt: new Date(a.addedAt).toISOString(), keyPrefix: a.apiKey.slice(0, 8) + '...', - apiKey: a.apiKey, tier: a.tier || 'unknown', capabilities: a.capabilities || {}, lastProbed: a.lastProbed || 0, diff --git a/src/client.js b/src/client.js index 9bf6195..88c0868 100644 --- a/src/client.js +++ b/src/client.js @@ -68,9 +68,11 @@ export class WindsurfClient { return new Promise((resolve, reject) => { const chunks = []; + let done = false; grpcStream(this.port, this.csrfToken, `${LS_SERVICE}/RawGetChatMessage`, body, { onData: (payload) => { + if (done) return; try { const parsed = parseRawResponse(payload); if (parsed.text) { @@ -80,6 +82,7 @@ export class WindsurfClient { const err = new Error(parsed.text.trim()); // Mark model-level errors so they don't count against the account err.isModelError = /permission_denied|failed_precondition/.test(parsed.text); + done = true; reject(err); return; } @@ -91,10 +94,14 @@ export class WindsurfClient { } }, onEnd: () => { + if (done) return; + done = true; onEnd?.(chunks); resolve(chunks); }, onError: (err) => { + if (done) return; + done = true; onError?.(err); reject(err); }, diff --git a/src/connect.js b/src/connect.js index eb7f2e3..4a5bc37 100644 --- a/src/connect.js +++ b/src/connect.js @@ -109,9 +109,13 @@ export class StreamingFrameParser { /** Drain all complete frames. Returns [{ flags, isEndStream, payload }]. */ drain() { + const MAX_FRAME_SIZE = 16 * 1024 * 1024; // 16 MB const frames = []; while (this.buffer.length >= 5) { const len = this.buffer.readUInt32BE(1); + if (len > MAX_FRAME_SIZE) { + throw new Error(`Frame size ${len} exceeds maximum ${MAX_FRAME_SIZE}`); + } if (this.buffer.length < 5 + len) break; const flags = this.buffer[0]; diff --git a/src/dashboard/api.js b/src/dashboard/api.js index e3b5b21..0340c71 100644 --- a/src/dashboard/api.js +++ b/src/dashboard/api.js @@ -162,10 +162,12 @@ export async function handleDashboardApi(method, subpath, body, req, res) { dirtyFiles: dirty.split('\n').slice(0, 20), }); } - await runShell(`git fetch origin ${before.branch || 'master'}`); - await runShell(`git reset --hard origin/${before.branch || 'master'}`); + const safeBranch = /^[\w.\-\/]+$/.test(before.branch) ? before.branch : 'master'; + await runShell(`git fetch origin ${safeBranch}`); + await runShell(`git reset --hard origin/${safeBranch}`); } - const pullCmd = `git pull origin ${before.branch || 'master'} --ff-only 2>&1`; + const safeBranch = /^[\w.\-\/]+$/.test(before.branch) ? before.branch : 'master'; + const pullCmd = `git pull origin ${safeBranch} --ff-only 2>&1`; const pull = dirty ? 'hard-reset applied' : await runShell(pullCmd); const after = await gitStatus(); const changed = before.commit !== after.commit; diff --git a/src/dashboard/index.html b/src/dashboard/index.html index e17dbf4..f207c64 100644 --- a/src/dashboard/index.html +++ b/src/dashboard/index.html @@ -2133,7 +2133,7 @@

控制台登录

const blockedCount = (a.blockedModels || []).length; const availCount = tierModels.length - blockedCount; const capsHtml = tierModels.length - ? ` + ${a.status === 'active' - ? `` - : ``} + ? `` + : ``} - + `; @@ -2494,7 +2494,7 @@

控制台登录

if (current.length > 0) { document.getElementById('model-list-current').innerHTML = `
当前清单 (${current.length})
-
${current.map(m => `${m}×`).join('')}
`; +
${current.map(m => `${this.esc(m)}×`).join('')}
`; } else { document.getElementById('model-list-current').innerHTML = '
清单为空
'; } @@ -2524,7 +2524,7 @@

控制台登录

${prov}
${models.map(m => { const inList = list.includes(m.id); - return `${m.name}`; + return `${this.esc(m.name)}`; }).join('')}
`).join('') || '
没有符合的模型
'; @@ -2577,11 +2577,11 @@

控制台登录

const p = pa[a.id]; return ` ${this.esc(a.email)} ${a.id} - ${p ? `${p.type}://${p.username ? p.username + '@' : ''}${p.host}:${p.port}` : '无(使用全局)'} + ${p ? `${this.esc(p.type)}://${p.username ? this.esc(p.username) + '@' : ''}${this.esc(p.host)}:${this.esc(String(p.port))}` : '无(使用全局)'}
- - ${p ? `` : ''} + + ${p ? `` : ''}
`; @@ -2647,19 +2647,41 @@

控制台登录

loadLogs() { this.logEntries = []; document.getElementById('log-container').innerHTML = ''; - // EventSource can't set custom headers, so pass the dashboard password - // via query string (same secret, only transmitted over same-origin). - const qs = this.password ? `?pwd=${encodeURIComponent(this.password)}` : ''; - this.sseConn = new EventSource('/dashboard/api/logs/stream' + qs); - this.sseConn.onmessage = (e) => { - try { - const entry = JSON.parse(e.data); - this.logEntries.push(entry); - if (this.logEntries.length > 500) this.logEntries.shift(); - this.renderLogEntry(entry); - } catch {} - }; - this.sseConn.onerror = () => {}; + + // Use fetch-based SSE to send password via header instead of URL + const headers = { 'Accept': 'text/event-stream' }; + if (this.password) headers['X-Dashboard-Password'] = this.password; + + fetch('/dashboard/api/logs/stream', { headers }) + .then(response => { + if (!response.ok) throw new Error('SSE connection failed'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const processChunk = ({ done, value }) => { + if (done) return; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const entry = JSON.parse(line.slice(6)); + this.logEntries.push(entry); + if (this.logEntries.length > 500) this.logEntries.shift(); + this.renderLogEntry(entry); + } catch {} + } + } + return reader.read().then(processChunk); + }; + + this.sseConn = { close: () => reader.cancel() }; + return reader.read().then(processChunk); + }) + .catch(() => {}); }, renderLogEntry(entry) { @@ -2961,7 +2983,7 @@

控制台登录

${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '-'}
- ${a.status === 'error' ? `` : ''} + ${a.status === 'error' ? `` : ''} ${a.errorCount > 0 ? `` : ''}
diff --git a/src/dashboard/model-access.js b/src/dashboard/model-access.js index da86df7..974d02d 100644 --- a/src/dashboard/model-access.js +++ b/src/dashboard/model-access.js @@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join } from 'path'; +import { log } from '../config.js'; const ACCESS_FILE = join(process.cwd(), 'model-access.json'); @@ -19,12 +20,16 @@ try { if (existsSync(ACCESS_FILE)) { Object.assign(_config, JSON.parse(readFileSync(ACCESS_FILE, 'utf-8'))); } -} catch {} +} catch (e) { + log.error('Failed to load model-access.json:', e.message); +} function save() { try { writeFileSync(ACCESS_FILE, JSON.stringify(_config, null, 2)); - } catch {} + } catch (e) { + log.error('Failed to save model-access.json:', e.message); + } } export function getModelAccessConfig() { diff --git a/src/dashboard/proxy-config.js b/src/dashboard/proxy-config.js index c263a04..9e308a6 100644 --- a/src/dashboard/proxy-config.js +++ b/src/dashboard/proxy-config.js @@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join } from 'path'; +import { log } from '../config.js'; const PROXY_FILE = join(process.cwd(), 'proxy.json'); @@ -18,12 +19,16 @@ try { if (existsSync(PROXY_FILE)) { Object.assign(_config, JSON.parse(readFileSync(PROXY_FILE, 'utf-8'))); } -} catch {} +} catch (e) { + log.error('Failed to load proxy.json:', e.message); +} function save() { try { writeFileSync(PROXY_FILE, JSON.stringify(_config, null, 2)); - } catch {} + } catch (e) { + log.error('Failed to save proxy.json:', e.message); + } } export function getProxyConfig() { diff --git a/src/grpc.js b/src/grpc.js index d923231..dcddb82 100644 --- a/src/grpc.js +++ b/src/grpc.js @@ -7,6 +7,26 @@ import http2 from 'http2'; import { log } from './config.js'; +// Session pool: reuse HTTP/2 connections per port +const _sessionPool = new Map(); + +function getSession(port) { + const key = `localhost:${port}`; + let session = _sessionPool.get(key); + if (!session || session.destroyed || session.closed) { + session = http2.connect(`http://localhost:${port}`); + session.on('error', (err) => { + log.debug(`HTTP/2 session error for port ${port}: ${err.message}`); + _sessionPool.delete(key); + }); + session.on('close', () => { + _sessionPool.delete(key); + }); + _sessionPool.set(key, session); + } + return session; +} + /** * Wrap a protobuf payload in a gRPC frame. * Format: 1 byte compression (0) + 4 bytes BE length + payload @@ -62,19 +82,19 @@ export function extractGrpcFrames(buf) { */ export function grpcUnary(port, csrfToken, path, body, timeout = 30000) { return new Promise((resolve, reject) => { - const client = http2.connect(`http://localhost:${port}`); + let settled = false; + const settle = (fn, ...args) => { + if (settled) return; + settled = true; + fn(...args); + }; + + const client = getSession(port); const chunks = []; let timer; - client.on('error', (err) => { - clearTimeout(timer); - client.close(); - reject(err); - }); - timer = setTimeout(() => { - client.close(); - reject(new Error('gRPC unary timeout')); + settle(reject, new Error('gRPC unary timeout')); }, timeout); const req = client.request({ @@ -96,20 +116,18 @@ export function grpcUnary(port, csrfToken, path, body, timeout = 30000) { req.on('end', () => { clearTimeout(timer); - client.close(); if (grpcStatus !== '0') { const msg = grpcMessage ? decodeURIComponent(grpcMessage) : `gRPC status ${grpcStatus}`; - reject(new Error(msg)); + settle(reject, new Error(msg)); return; } const full = Buffer.concat(chunks); - resolve(stripGrpcFrame(full)); + settle(resolve, stripGrpcFrame(full)); }); req.on('error', (err) => { clearTimeout(timer); - client.close(); - reject(err); + settle(reject, err); }); req.write(body); @@ -130,18 +148,14 @@ export function grpcUnary(port, csrfToken, path, body, timeout = 30000) { export function grpcStream(port, csrfToken, path, body, opts = {}) { const { onData, onEnd, onError, timeout = 300000 } = opts; - const client = http2.connect(`http://localhost:${port}`); + let done = false; + const client = getSession(port); let timer; let pendingBuf = Buffer.alloc(0); - client.on('error', (err) => { - clearTimeout(timer); - client.close(); - onError?.(err); - }); - timer = setTimeout(() => { - client.close(); + if (done) return; + done = true; onError?.(new Error('gRPC stream timeout')); }, timeout); @@ -179,7 +193,8 @@ export function grpcStream(port, csrfToken, path, body, opts = {}) { req.on('end', () => { clearTimeout(timer); - client.close(); + if (done) return; + done = true; if (grpcStatus !== '0') { const msg = grpcMessage ? decodeURIComponent(grpcMessage) : `gRPC status ${grpcStatus}`; onError?.(new Error(msg)); @@ -190,7 +205,8 @@ export function grpcStream(port, csrfToken, path, body, opts = {}) { req.on('error', (err) => { clearTimeout(timer); - client.close(); + if (done) return; + done = true; onError?.(err); }); diff --git a/src/handlers/chat.js b/src/handlers/chat.js index 6ecdf79..9e68c07 100644 --- a/src/handlers/chat.js +++ b/src/handlers/chat.js @@ -264,6 +264,7 @@ export async function handleChatCompletions(body) { // Non-stream: retry with a different account on model-not-available errors const tried = []; let lastErr = null; + let _msgChars = 0; // Dynamic: try every active account in the pool (capped at 10) so a // large pool with many rate-limited accounts can still fall through // to a free one. Was hardcoded 3 — in pools bigger than 3 with the @@ -294,7 +295,7 @@ export async function handleChatCompletions(body) { const rl = await checkMessageRateLimit(acct.apiKey, px); if (!rl.hasCapacity) { log.warn(`Preflight: ${acct.email} has no capacity (remaining=${rl.messagesRemaining}), skipping`); - markRateLimited(acct.id, modelKey); + markRateLimited(acct.apiKey, 5 * 60 * 1000, modelKey); continue; } } catch (e) { @@ -312,7 +313,7 @@ export async function handleChatCompletions(body) { log.info('Chat: cascade reuse skipped — LS port changed'); reuseEntry = null; } - const _msgChars = (messages || []).reduce((n, m) => { + _msgChars = (messages || []).reduce((n, m) => { const c = m?.content; return n + (typeof c === 'string' ? c.length : Array.isArray(c) ? c.reduce((k, p) => k + (typeof p?.text === 'string' ? p.text.length : 0), 0) : 0); }, 0); @@ -571,6 +572,10 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages, // even though they would have worked (issue #5). const maxAttempts = Math.min(10, Math.max(3, getAccountList().filter(a => a.status === 'active').length)); + try { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (abortController.signal.aborted) return; + // Accumulate chunks so we can cache a successful response at the end. let accText = ''; let accThinking = ''; @@ -657,10 +662,7 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages, } }; - try { - for (let attempt = 0; attempt < maxAttempts; attempt++) { - if (abortController.signal.aborted) return; - let acct = null; + let acct = null; if (reuseEntry && attempt === 0) { acct = acquireAccountByKey(reuseEntry.apiKey, modelKey); if (!acct) { @@ -682,7 +684,7 @@ function streamResponse(id, created, model, modelKey, messages, cascadeMessages, const rl = await checkMessageRateLimit(acct.apiKey, px); if (!rl.hasCapacity) { log.warn(`Preflight: ${acct.email} has no capacity (remaining=${rl.messagesRemaining}), skipping`); - markRateLimited(acct.id, modelKey); + markRateLimited(acct.apiKey, 5 * 60 * 1000, modelKey); continue; } } catch (e) { diff --git a/src/langserver.js b/src/langserver.js index 5f7cc97..c80b822 100644 --- a/src/langserver.js +++ b/src/langserver.js @@ -20,13 +20,14 @@ const DEFAULT_API_URL = 'https://server.self-serve.windsurf.com'; // Pool: key -> { process, port, csrfToken, proxy, startedAt, ready } const _pool = new Map(); +const _pending = new Map(); let _nextPort = DEFAULT_PORT + 1; let _binaryPath = DEFAULT_BINARY; let _apiServerUrl = DEFAULT_API_URL; function proxyKey(proxy) { if (!proxy || !proxy.host) return 'default'; - return `px_${proxy.host.replace(/\./g, '_')}_${proxy.port}`; + return `px_${proxy.host.replace(/[^a-zA-Z0-9]/g, '_')}_${proxy.port}`; } function proxyUrl(proxy) { @@ -74,101 +75,116 @@ export async function ensureLs(proxy = null) { const existing = _pool.get(key); if (existing && existing.ready) return existing; - const isDefault = key === 'default'; - const port = isDefault ? DEFAULT_PORT : _nextPort++; + // Check if another call is already starting this LS + const pending = _pending.get(key); + if (pending) return pending; - // If something is already listening on the default port (e.g. leftover from - // a previous crashed run), adopt it rather than fight for the port. - if (isDefault && await isPortInUse(port)) { - log.info(`LS default port ${port} already in use — adopting existing instance`); - const entry = { - process: null, port, csrfToken: DEFAULT_CSRF, - proxy: null, startedAt: Date.now(), ready: true, - workspaceInit: null, sessionId: null, - }; - _pool.set(key, entry); - return entry; - } + // Start the LS and store the promise + const startPromise = (async () => { + try { + const isDefault = key === 'default'; + const port = isDefault ? DEFAULT_PORT : _nextPort++; - const dataDir = `/opt/windsurf/data/${key}`; - try { execSync(`mkdir -p ${dataDir}/db`, { stdio: 'ignore' }); } catch {} + // If something is already listening on the default port (e.g. leftover from + // a previous crashed run), adopt it rather than fight for the port. + if (isDefault && await isPortInUse(port)) { + log.info(`LS default port ${port} already in use — adopting existing instance`); + const entry = { + process: null, port, csrfToken: DEFAULT_CSRF, + proxy: null, startedAt: Date.now(), ready: true, + workspaceInit: null, sessionId: null, + }; + _pool.set(key, entry); + return entry; + } - const args = [ - `--api_server_url=${_apiServerUrl}`, - `--server_port=${port}`, - `--csrf_token=${DEFAULT_CSRF}`, - `--register_user_url=https://api.codeium.com/register_user/`, - `--codeium_dir=${dataDir}`, - `--database_dir=${dataDir}/db`, - '--enable_local_search=false', - '--enable_index_service=false', - '--enable_lsp=false', - '--detect_proxy=false', - ]; + const dataDir = `/opt/windsurf/data/${key}`; + try { execSync(`mkdir -p ${dataDir}/db`, { stdio: 'ignore' }); } catch {} - const env = { ...process.env, HOME: '/root' }; - const pUrl = proxyUrl(proxy); - if (pUrl) { - env.HTTPS_PROXY = pUrl; - env.HTTP_PROXY = pUrl; - env.https_proxy = pUrl; - env.http_proxy = pUrl; - } + const args = [ + `--api_server_url=${_apiServerUrl}`, + `--server_port=${port}`, + `--csrf_token=${DEFAULT_CSRF}`, + `--register_user_url=https://api.codeium.com/register_user/`, + `--codeium_dir=${dataDir}`, + `--database_dir=${dataDir}/db`, + '--enable_local_search=false', + '--enable_index_service=false', + '--enable_lsp=false', + '--detect_proxy=false', + ]; - log.info(`Starting LS instance key=${key} port=${port} proxy=${pUrl || 'none'}`); + const env = { ...process.env, HOME: '/root' }; + const pUrl = proxyUrl(proxy); + if (pUrl) { + env.HTTPS_PROXY = pUrl; + env.HTTP_PROXY = pUrl; + env.https_proxy = pUrl; + env.http_proxy = pUrl; + } - const proc = spawn(_binaryPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - env, - }); + log.info(`Starting LS instance key=${key} port=${port} proxy=${pUrl || 'none'}`); - proc.stdout.on('data', (data) => { - const lines = data.toString().trim().split('\n'); - for (const line of lines) { - if (!line) continue; - if (/ERROR|error/.test(line)) log.error(`[LS:${key}] ${line}`); - else log.debug(`[LS:${key}] ${line}`); - } - }); - proc.stderr.on('data', (data) => { - const line = data.toString().trim(); - if (line) log.debug(`[LS:${key}:err] ${line}`); - }); - proc.on('exit', (code, signal) => { - log.warn(`LS instance ${key} exited: code=${code} signal=${signal}`); - const gone = _pool.get(key); - _pool.delete(key); - if (gone?.port) { - import('./conversation-pool.js').then(m => m.invalidateFor({ lsPort: gone.port })).catch(() => {}); - } - }); - proc.on('error', (err) => { - log.error(`LS instance ${key} spawn error: ${err.message}`); - _pool.delete(key); - }); + const proc = spawn(_binaryPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); - const entry = { - process: proc, port, csrfToken: DEFAULT_CSRF, - proxy, startedAt: Date.now(), ready: false, - // One-shot Cascade workspace init promise. cascadeChat() awaits this so - // the heavy InitializePanelState / AddTrackedWorkspace / UpdateWorkspaceTrust - // trio only runs once per LS lifetime instead of once per request. - workspaceInit: null, - sessionId: null, - }; - _pool.set(key, entry); + proc.stdout.on('data', (data) => { + const lines = data.toString().trim().split('\n'); + for (const line of lines) { + if (!line) continue; + if (/ERROR|error/.test(line)) log.error(`[LS:${key}] ${line}`); + else log.debug(`[LS:${key}] ${line}`); + } + }); + proc.stderr.on('data', (data) => { + const line = data.toString().trim(); + if (line) log.debug(`[LS:${key}:err] ${line}`); + }); + proc.on('exit', (code, signal) => { + log.warn(`LS instance ${key} exited: code=${code} signal=${signal}`); + const gone = _pool.get(key); + _pool.delete(key); + if (gone?.port) { + import('./conversation-pool.js').then(m => m.invalidateFor({ lsPort: gone.port })).catch(() => {}); + } + }); + proc.on('error', (err) => { + log.error(`LS instance ${key} spawn error: ${err.message}`); + _pool.delete(key); + }); - try { - await waitPortReady(port, 25000); - entry.ready = true; - log.info(`LS instance ${key} ready on port ${port}`); - } catch (err) { - log.error(`LS instance ${key} failed to become ready: ${err.message}`); - try { proc.kill('SIGKILL'); } catch {} - _pool.delete(key); - throw err; - } - return entry; + const entry = { + process: proc, port, csrfToken: DEFAULT_CSRF, + proxy, startedAt: Date.now(), ready: false, + // One-shot Cascade workspace init promise. cascadeChat() awaits this so + // the heavy InitializePanelState / AddTrackedWorkspace / UpdateWorkspaceTrust + // trio only runs once per LS lifetime instead of once per request. + workspaceInit: null, + sessionId: null, + }; + _pool.set(key, entry); + + await waitPortReady(port, 25000); + entry.ready = true; + log.info(`LS instance ${key} ready on port ${port}`); + return entry; + } catch (err) { + log.error(`LS instance ${key} failed to become ready: ${err.message}`); + const entry = _pool.get(key); + if (entry?.process) { + try { entry.process.kill('SIGKILL'); } catch {} + } + _pool.delete(key); + throw err; + } finally { + _pending.delete(key); + } + })(); + + _pending.set(key, startPromise); + return startPromise; } /** diff --git a/src/proto.js b/src/proto.js index 1753b69..0d59550 100644 --- a/src/proto.js +++ b/src/proto.js @@ -22,6 +22,17 @@ export function encodeVarint(value) { } return Buffer.from(bytes); } + // Use BigInt for values > 32 bits + if (v > 0xFFFFFFFF) { + let b = BigInt(v); + while (b > 0n) { + let byte = Number(b & 0x7Fn); + b >>= 7n; + if (b > 0n) byte |= 0x80; + bytes.push(byte); + } + return Buffer.from(bytes); + } do { let byte = v & 0x7F; v >>>= 7; @@ -35,7 +46,24 @@ export function decodeVarint(buf, offset = 0) { let result = 0, shift = 0, pos = offset; while (pos < buf.length) { const byte = buf[pos++]; - result |= (byte & 0x7F) << shift; + if (shift < 28) { + result |= (byte & 0x7F) << shift; + } else { + // Switch to BigInt for values requiring > 32 bits + let big = BigInt(result); + big |= BigInt(byte & 0x7F) << BigInt(shift); + shift += 7; + if (!(byte & 0x80)) return { value: Number(big), length: pos - offset }; + + while (pos < buf.length) { + const b = buf[pos++]; + big |= BigInt(b & 0x7F) << BigInt(shift); + if (!(b & 0x80)) break; + shift += 7; + if (shift >= 64) throw new Error('Varint overflow'); + } + return { value: Number(big), length: pos - offset }; + } if (!(byte & 0x80)) break; shift += 7; if (shift >= 64) throw new Error('Varint overflow'); diff --git a/src/server.js b/src/server.js index 4f04d28..5ece583 100644 --- a/src/server.js +++ b/src/server.js @@ -46,10 +46,21 @@ const VERSION_INFO = (() => { return { version: pkgVersion, commit, commitMessage, commitDate, branch }; })(); +const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; - req.on('data', c => chunks.push(c)); + let size = 0; + req.on('data', c => { + size += c.length; + if (size > MAX_BODY_SIZE) { + req.destroy(); + reject(new Error('Request body too large')); + return; + } + chunks.push(c); + }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); req.on('error', reject); }); @@ -121,12 +132,20 @@ async function route(req, res) { return handleDashboardApi(method, subpath, body, req, res); } - // ─── Auth management (no API key required) ───────────── + // ─── Auth status (public, no sensitive data) ─────────── if (path === '/auth/status') { return json(res, 200, { authenticated: isAuthenticated(), ...getAccountCount() }); } + // ─── API endpoints (require API key) ──────────────────── + + if (!validateApiKey(extractToken(req))) { + return json(res, 401, { error: { message: 'Invalid API key', type: 'auth_error' } }); + } + + // ─── Auth management (requires API key) ──────────────── + if (path === '/auth/accounts' && method === 'GET') { return json(res, 200, { accounts: getAccountList() }); } @@ -192,12 +211,6 @@ async function route(req, res) { } } - // ─── API endpoints (require API key) ──────────────────── - - if (!validateApiKey(extractToken(req))) { - return json(res, 401, { error: { message: 'Invalid API key', type: 'auth_error' } }); - } - if (path === '/v1/models' && method === 'GET') { return json(res, 200, handleModels()); }