diff --git a/src/dashboard/api.js b/src/dashboard/api.js index 6fc65bc..668cf17 100644 --- a/src/dashboard/api.js +++ b/src/dashboard/api.js @@ -123,7 +123,9 @@ async function processWindsurfLogin({ email, password, loginProxy, autoAdd }) { /** * Handle all /dashboard/api/* requests. */ -export async function handleDashboardApi(method, subpath, body, req, res) { +export async function handleDashboardApi(method, subpath, body, req, res, deps = {}) { + const ensureLsForAccountFn = deps.ensureLsForAccount || ensureLsForAccount; + const probeAccountFn = deps.probeAccount || probeAccount; if (method === 'OPTIONS') return json(res, 204, ''); // Auth check (except for auth verification endpoint) @@ -288,16 +290,19 @@ export async function handleDashboardApi(method, subpath, body, req, res) { if (subpath === '/accounts' && method === 'POST') { try { + // Validate required credentials before any proxy validation/network checks. if (!body.api_key && !body.token) { return json(res, 400, { error: 'Provide api_key or token' }); } + // 1. Validate proxy first (if provided) - fail early before touching account store let parsedProxy = null; if (body.proxy) { parsedProxy = parseProxyUrl(body.proxy); if (!parsedProxy) { return json(res, 400, { error: 'ERR_PROXY_FORMAT_INVALID' }); } + // Validate proxy host (respect ALLOW_PRIVATE_PROXY_HOSTS config) try { if (config.allowPrivateProxyHosts) { await validateHostFormat(parsedProxy.host); @@ -309,10 +314,12 @@ export async function handleDashboardApi(method, subpath, body, req, res) { } } + // 2. Create account only after proxy validation passes const account = body.api_key ? addAccountByKey(body.api_key, body.label) : await addAccountByToken(body.token, body.label); + // 3. Bind proxy to the newly created account if (parsedProxy) { setAccountProxy(account.id, parsedProxy); ensureLsForAccount(account.id).catch(e => log.warn(`LS ensure failed: ${e.message}`)); diff --git a/src/dashboard/index.html b/src/dashboard/index.html index 6fecdd6..31599c1 100644 --- a/src/dashboard/index.html +++ b/src/dashboard/index.html @@ -2350,6 +2350,12 @@

控制台登录

I18n.apply(); }, + translateError(errorCode, fallbackKey = 'error.unknown') { + const errKey = errorCode ? `error.${errorCode}` : ''; + if (errKey && I18n.t(errKey) !== errKey) return I18n.t(errKey); + return errorCode || I18n.t(fallbackKey); + }, + async toggleLang() { const newLang = I18n.locale === 'zh' || I18n.locale === 'zh-CN' ? 'en' : 'zh'; await I18n.setLocale(newLang); @@ -2854,10 +2860,10 @@

控制台登录

input.value = ''; this.loadAccounts(); } else { - this.toast(r.error || I18n.t('toast.addFailed'), 'error'); + this.toast(this.translateError(r.error, 'toast.addFailed'), 'error'); } } catch (err) { - this.toast(err.message, 'error'); + this.toast(this.translateError(err.message, 'error.unknown'), 'error'); } finally { if (btn) btn.disabled = originalDisabled || false; } @@ -3433,7 +3439,7 @@

控制台登录

this.toast(I18n.t('toast.credits.refreshed')); this.loadAccounts(); } else { - this.toast(r.error || I18n.t('toast.credits.refreshFailed'), 'error'); + this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error'); } } catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); } }, @@ -3448,7 +3454,7 @@

控制台登录

this.toast(I18n.t('toast.credits.allRefreshed', { ok: okCount, fail: failCount })); this.loadAccounts(); } else { - this.toast(r.error || I18n.t('toast.credits.refreshFailed'), 'error'); + this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error'); } } catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); } }, @@ -3463,7 +3469,7 @@

控制台登录

this.toast(I18n.t('toast.probeAllComplete', { summary })); this.loadAccounts(); } else { - this.toast(r.error || I18n.t('error.probeFailed'), 'error'); + this.toast(this.translateError(r.error, 'error.probeFailed'), 'error'); } } catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); } }, @@ -3477,7 +3483,7 @@

控制台登录

this.toast(I18n.t('toast.probeComplete', { tier: tierLabel[r.tier] || r.tier })); this.loadAccounts(); } else { - this.toast(r.error || I18n.t('error.probeFailed'), 'error'); + this.toast(this.translateError(r.error, 'error.probeFailed'), 'error'); } } catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); } }, @@ -3603,7 +3609,7 @@

控制台登录

root.remove(); self.loadAccounts(); } else { - self.toast(r.error || I18n.t('error.saveFailed'), 'error'); + self.toast(self.translateError(r.error, 'error.saveFailed'), 'error'); okBtn.disabled = false; cancelBtn.disabled = false; okBtn.textContent = I18n.t('action.save'); } } catch (e) { @@ -3638,7 +3644,7 @@

控制台登录

const body = type === 'api_key' ? { api_key: key, label, proxy } : { token: key, label, proxy }; const r = await this.api('POST', '/accounts', body); if (r.success) { this.toast(I18n.t('account.addSuccess')); document.getElementById('acc-key').value = ''; document.getElementById('acc-label').value = ''; document.getElementById('acc-proxy').value = ''; this.loadAccounts(); } - else this.toast(r.error || I18n.t('toast.addFailed'), 'error'); + else this.toast(this.translateError(r.error, 'toast.addFailed'), 'error'); }, async discoverLocalWindsurf() { diff --git a/test/account-add-proxy.test.js b/test/account-add-proxy.test.js new file mode 100644 index 0000000..319f552 --- /dev/null +++ b/test/account-add-proxy.test.js @@ -0,0 +1,107 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { config } from '../src/config.js'; +import { configureBindHost, getAccountList, removeAccount } from '../src/auth.js'; +import { handleDashboardApi } from '../src/dashboard/api.js'; +import { getProxyConfig, removeProxy } from '../src/dashboard/proxy-config.js'; + +const originalAllowPrivateProxyHosts = config.allowPrivateProxyHosts; +const createdAccountIds = []; +const testDeps = { + ensureLsForAccount: async () => {}, + probeAccount: async () => ({}), +}; + +function fakeRes() { + return { + statusCode: 0, + body: '', + writeHead(status) { this.statusCode = status; }, + end(chunk) { this.body += chunk ? String(chunk) : ''; }, + json() { return this.body ? JSON.parse(this.body) : null; }, + }; +} + +function accountPayload(extra = {}) { + return { + api_key: `test-key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + label: 'proxy-test', + ...extra, + }; +} + +afterEach(() => { + config.allowPrivateProxyHosts = originalAllowPrivateProxyHosts; + configureBindHost('0.0.0.0'); + while (createdAccountIds.length) { + const id = createdAccountIds.pop(); + removeProxy('account', id); + removeAccount(id); + } +}); + +describe('dashboard account add with proxy validation', () => { + it('rejects invalid proxy format before creating an account', async () => { + configureBindHost('127.0.0.1'); + const before = getAccountList().length; + + const res = fakeRes(); + await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'invalid-proxy-url' }), { headers: {} }, res, testDeps); + + assert.equal(res.statusCode, 400); + assert.equal(res.json().error, 'ERR_PROXY_FORMAT_INVALID'); + assert.equal(getAccountList().length, before); + }); + + it('rejects private proxy hosts by default before creating an account', async () => { + configureBindHost('127.0.0.1'); + config.allowPrivateProxyHosts = false; + const before = getAccountList().length; + + const res = fakeRes(); + await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'http://127.0.0.1:8080' }), { headers: {} }, res, testDeps); + + assert.equal(res.statusCode, 400); + assert.match(String(res.json().error || ''), /ERR_PROXY_PRIVATE_IP|ERR_PROXY_PRIVATE_HOST/); + assert.equal(getAccountList().length, before); + }); + + it('creates account and binds proxy when proxy is valid', async () => { + configureBindHost('127.0.0.1'); + config.allowPrivateProxyHosts = true; + const before = getAccountList().length; + + const res = fakeRes(); + await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'http://127.0.0.1:8080' }), { headers: {} }, res, testDeps); + + assert.equal(res.statusCode, 200); + assert.equal(res.json().success, true); + const accountId = res.json().account?.id; + assert.ok(accountId); + createdAccountIds.push(accountId); + assert.equal(getAccountList().length, before + 1); + assert.deepEqual(getProxyConfig().perAccount[accountId], { + type: 'http', + host: '127.0.0.1', + port: 8080, + username: '', + password: '', + }); + }); + + it('creates account without proxy binding when proxy is omitted', async () => { + configureBindHost('127.0.0.1'); + const before = getAccountList().length; + + const res = fakeRes(); + await handleDashboardApi('POST', '/accounts', accountPayload(), { headers: {} }, res, testDeps); + + assert.equal(res.statusCode, 200); + assert.equal(res.json().success, true); + const accountId = res.json().account?.id; + assert.ok(accountId); + createdAccountIds.push(accountId); + assert.equal(getAccountList().length, before + 1); + assert.equal(getProxyConfig().perAccount[accountId], undefined); + }); +});