commons-proxy 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +757 -0
- package/bin/cli.js +146 -0
- package/package.json +97 -0
- package/public/Complaint Details.pdf +0 -0
- package/public/Cyber Crime Portal.pdf +0 -0
- package/public/app.js +229 -0
- package/public/css/src/input.css +523 -0
- package/public/css/style.css +1 -0
- package/public/favicon.png +0 -0
- package/public/index.html +549 -0
- package/public/js/components/account-manager.js +356 -0
- package/public/js/components/add-account-modal.js +414 -0
- package/public/js/components/claude-config.js +420 -0
- package/public/js/components/dashboard/charts.js +605 -0
- package/public/js/components/dashboard/filters.js +362 -0
- package/public/js/components/dashboard/stats.js +110 -0
- package/public/js/components/dashboard.js +236 -0
- package/public/js/components/logs-viewer.js +100 -0
- package/public/js/components/models.js +36 -0
- package/public/js/components/server-config.js +349 -0
- package/public/js/config/constants.js +102 -0
- package/public/js/data-store.js +375 -0
- package/public/js/settings-store.js +58 -0
- package/public/js/store.js +99 -0
- package/public/js/translations/en.js +367 -0
- package/public/js/translations/id.js +412 -0
- package/public/js/translations/pt.js +308 -0
- package/public/js/translations/tr.js +358 -0
- package/public/js/translations/zh.js +373 -0
- package/public/js/utils/account-actions.js +189 -0
- package/public/js/utils/error-handler.js +96 -0
- package/public/js/utils/model-config.js +42 -0
- package/public/js/utils/ui-logger.js +143 -0
- package/public/js/utils/validators.js +77 -0
- package/public/js/utils.js +69 -0
- package/public/proxy-server-64.png +0 -0
- package/public/views/accounts.html +361 -0
- package/public/views/dashboard.html +484 -0
- package/public/views/logs.html +97 -0
- package/public/views/models.html +331 -0
- package/public/views/settings.html +1327 -0
- package/src/account-manager/credentials.js +378 -0
- package/src/account-manager/index.js +462 -0
- package/src/account-manager/onboarding.js +112 -0
- package/src/account-manager/rate-limits.js +369 -0
- package/src/account-manager/storage.js +160 -0
- package/src/account-manager/strategies/base-strategy.js +109 -0
- package/src/account-manager/strategies/hybrid-strategy.js +339 -0
- package/src/account-manager/strategies/index.js +79 -0
- package/src/account-manager/strategies/round-robin-strategy.js +76 -0
- package/src/account-manager/strategies/sticky-strategy.js +138 -0
- package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
- package/src/account-manager/strategies/trackers/index.js +9 -0
- package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
- package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
- package/src/auth/database.js +169 -0
- package/src/auth/oauth.js +548 -0
- package/src/auth/token-extractor.js +117 -0
- package/src/cli/accounts.js +648 -0
- package/src/cloudcode/index.js +29 -0
- package/src/cloudcode/message-handler.js +510 -0
- package/src/cloudcode/model-api.js +248 -0
- package/src/cloudcode/rate-limit-parser.js +235 -0
- package/src/cloudcode/request-builder.js +93 -0
- package/src/cloudcode/session-manager.js +47 -0
- package/src/cloudcode/sse-parser.js +121 -0
- package/src/cloudcode/sse-streamer.js +293 -0
- package/src/cloudcode/streaming-handler.js +615 -0
- package/src/config.js +125 -0
- package/src/constants.js +407 -0
- package/src/errors.js +242 -0
- package/src/fallback-config.js +29 -0
- package/src/format/content-converter.js +193 -0
- package/src/format/index.js +20 -0
- package/src/format/request-converter.js +255 -0
- package/src/format/response-converter.js +120 -0
- package/src/format/schema-sanitizer.js +673 -0
- package/src/format/signature-cache.js +88 -0
- package/src/format/thinking-utils.js +648 -0
- package/src/index.js +148 -0
- package/src/modules/usage-stats.js +205 -0
- package/src/providers/anthropic-provider.js +258 -0
- package/src/providers/base-provider.js +157 -0
- package/src/providers/cloudcode.js +94 -0
- package/src/providers/copilot.js +399 -0
- package/src/providers/github-provider.js +287 -0
- package/src/providers/google-provider.js +192 -0
- package/src/providers/index.js +211 -0
- package/src/providers/openai-compatible.js +265 -0
- package/src/providers/openai-provider.js +271 -0
- package/src/providers/openrouter-provider.js +325 -0
- package/src/providers/setup.js +83 -0
- package/src/server.js +870 -0
- package/src/utils/claude-config.js +245 -0
- package/src/utils/helpers.js +51 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/native-module-helper.js +162 -0
- package/src/webui/index.js +1134 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebUI Module - Optional web interface for account management
|
|
3
|
+
*
|
|
4
|
+
* This module provides a web-based UI for:
|
|
5
|
+
* - Dashboard with real-time model quota visualization
|
|
6
|
+
* - Account management (add via OAuth, enable/disable, refresh, remove)
|
|
7
|
+
* - Live server log streaming with filtering
|
|
8
|
+
* - Claude CLI configuration editor
|
|
9
|
+
*
|
|
10
|
+
* Usage in server.js:
|
|
11
|
+
* import { mountWebUI } from './webui/index.js';
|
|
12
|
+
* mountWebUI(app, __dirname, accountManager);
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import crypto from 'crypto';
|
|
17
|
+
import { readFileSync } from 'fs';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import express from 'express';
|
|
20
|
+
import { getPublicConfig, saveConfig, config } from '../config.js';
|
|
21
|
+
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS, PROVIDER_CONFIG, PROVIDER_NAMES } from '../constants.js';
|
|
22
|
+
import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
|
|
23
|
+
import { logger } from '../utils/logger.js';
|
|
24
|
+
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
|
|
25
|
+
import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
|
|
26
|
+
import { getAllAuthProviders, getAuthProvider } from '../providers/index.js';
|
|
27
|
+
|
|
28
|
+
// Get package version
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = path.dirname(__filename);
|
|
31
|
+
let packageVersion = '1.0.0';
|
|
32
|
+
try {
|
|
33
|
+
const packageJsonPath = path.join(__dirname, '../../package.json');
|
|
34
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
35
|
+
packageVersion = packageJson.version;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
logger.warn('[WebUI] Could not read package.json version, using default');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// OAuth state storage (state -> { server, verifier, state, timestamp })
|
|
41
|
+
// Maps state ID to active OAuth flow data
|
|
42
|
+
const pendingOAuthFlows = new Map();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* WebUI Helper Functions - Direct account manipulation
|
|
46
|
+
* These functions work around AccountManager's limited API by directly
|
|
47
|
+
* manipulating the accounts.json config file (non-invasive approach for PR)
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Set account enabled/disabled state
|
|
52
|
+
*/
|
|
53
|
+
async function setAccountEnabled(email, enabled) {
|
|
54
|
+
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
|
55
|
+
const account = accounts.find(a => a.email === email);
|
|
56
|
+
if (!account) {
|
|
57
|
+
throw new Error(`Account ${email} not found`);
|
|
58
|
+
}
|
|
59
|
+
account.enabled = enabled;
|
|
60
|
+
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
|
|
61
|
+
logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Remove account from config
|
|
66
|
+
*/
|
|
67
|
+
async function removeAccount(email) {
|
|
68
|
+
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
|
69
|
+
const index = accounts.findIndex(a => a.email === email);
|
|
70
|
+
if (index === -1) {
|
|
71
|
+
throw new Error(`Account ${email} not found`);
|
|
72
|
+
}
|
|
73
|
+
accounts.splice(index, 1);
|
|
74
|
+
// Adjust activeIndex if needed
|
|
75
|
+
const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex;
|
|
76
|
+
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex);
|
|
77
|
+
logger.info(`[WebUI] Account ${email} removed`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Add new account to config
|
|
82
|
+
* @throws {Error} If MAX_ACCOUNTS limit is reached (for new accounts only)
|
|
83
|
+
*/
|
|
84
|
+
async function addAccount(accountData) {
|
|
85
|
+
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
|
86
|
+
|
|
87
|
+
// Check if account already exists
|
|
88
|
+
const existingIndex = accounts.findIndex(a => a.email === accountData.email);
|
|
89
|
+
if (existingIndex !== -1) {
|
|
90
|
+
// Update existing account
|
|
91
|
+
accounts[existingIndex] = {
|
|
92
|
+
...accounts[existingIndex],
|
|
93
|
+
...accountData,
|
|
94
|
+
enabled: true,
|
|
95
|
+
isInvalid: false,
|
|
96
|
+
invalidReason: null,
|
|
97
|
+
addedAt: accounts[existingIndex].addedAt || new Date().toISOString()
|
|
98
|
+
};
|
|
99
|
+
logger.info(`[WebUI] Account ${accountData.email} updated`);
|
|
100
|
+
} else {
|
|
101
|
+
// Check MAX_ACCOUNTS limit before adding new account
|
|
102
|
+
if (accounts.length >= MAX_ACCOUNTS) {
|
|
103
|
+
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached. Update maxAccounts in config to increase the limit.`);
|
|
104
|
+
}
|
|
105
|
+
// Add new account
|
|
106
|
+
accounts.push({
|
|
107
|
+
...accountData,
|
|
108
|
+
enabled: true,
|
|
109
|
+
isInvalid: false,
|
|
110
|
+
invalidReason: null,
|
|
111
|
+
modelRateLimits: {},
|
|
112
|
+
lastUsed: null,
|
|
113
|
+
addedAt: new Date().toISOString()
|
|
114
|
+
});
|
|
115
|
+
logger.info(`[WebUI] Account ${accountData.email} added`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Auth Middleware - Optional password protection for WebUI
|
|
123
|
+
* Password can be set via WEBUI_PASSWORD env var or config.json
|
|
124
|
+
*/
|
|
125
|
+
function createAuthMiddleware() {
|
|
126
|
+
return (req, res, next) => {
|
|
127
|
+
const password = config.webuiPassword;
|
|
128
|
+
if (!password) return next();
|
|
129
|
+
|
|
130
|
+
// Determine if this path should be protected
|
|
131
|
+
const isApiRoute = req.path.startsWith('/api/');
|
|
132
|
+
const isAuthUrl = req.path === '/api/auth/url';
|
|
133
|
+
const isConfigGet = req.path === '/api/config' && req.method === 'GET';
|
|
134
|
+
const isProtected = (isApiRoute && !isAuthUrl && !isConfigGet) || req.path === '/account-limits' || req.path === '/health';
|
|
135
|
+
|
|
136
|
+
if (isProtected) {
|
|
137
|
+
const providedPassword = req.headers['x-webui-password'] || req.query.password;
|
|
138
|
+
if (providedPassword !== password) {
|
|
139
|
+
return res.status(401).json({ status: 'error', error: 'Unauthorized: Password required' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
next();
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mount WebUI routes and middleware on Express app
|
|
148
|
+
* @param {Express} app - Express application instance
|
|
149
|
+
* @param {string} dirname - __dirname of the calling module (for static file path)
|
|
150
|
+
* @param {AccountManager} accountManager - Account manager instance
|
|
151
|
+
*/
|
|
152
|
+
export function mountWebUI(app, dirname, accountManager) {
|
|
153
|
+
// Apply auth middleware
|
|
154
|
+
app.use(createAuthMiddleware());
|
|
155
|
+
|
|
156
|
+
// Serve static files from public directory
|
|
157
|
+
app.use(express.static(path.join(dirname, '../public')));
|
|
158
|
+
|
|
159
|
+
// ==========================================
|
|
160
|
+
// Account Management API
|
|
161
|
+
// ==========================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* GET /api/accounts - List all accounts with status
|
|
165
|
+
*/
|
|
166
|
+
app.get('/api/accounts', async (req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const status = accountManager.getStatus();
|
|
169
|
+
res.json({
|
|
170
|
+
status: 'ok',
|
|
171
|
+
accounts: status.accounts,
|
|
172
|
+
summary: {
|
|
173
|
+
total: status.total,
|
|
174
|
+
available: status.available,
|
|
175
|
+
rateLimited: status.rateLimited,
|
|
176
|
+
invalid: status.invalid
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
} catch (error) {
|
|
180
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* POST /api/accounts/:email/refresh - Refresh specific account token
|
|
186
|
+
*/
|
|
187
|
+
app.post('/api/accounts/:email/refresh', async (req, res) => {
|
|
188
|
+
try {
|
|
189
|
+
const { email } = req.params;
|
|
190
|
+
accountManager.clearTokenCache(email);
|
|
191
|
+
accountManager.clearProjectCache(email);
|
|
192
|
+
res.json({
|
|
193
|
+
status: 'ok',
|
|
194
|
+
message: `Token cache cleared for ${email}`
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* POST /api/accounts/:email/toggle - Enable/disable account
|
|
203
|
+
*/
|
|
204
|
+
app.post('/api/accounts/:email/toggle', async (req, res) => {
|
|
205
|
+
try {
|
|
206
|
+
const { email } = req.params;
|
|
207
|
+
const { enabled } = req.body;
|
|
208
|
+
|
|
209
|
+
if (typeof enabled !== 'boolean') {
|
|
210
|
+
return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await setAccountEnabled(email, enabled);
|
|
214
|
+
|
|
215
|
+
// Reload AccountManager to pick up changes
|
|
216
|
+
await accountManager.reload();
|
|
217
|
+
|
|
218
|
+
res.json({
|
|
219
|
+
status: 'ok',
|
|
220
|
+
message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}`
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* DELETE /api/accounts/:email - Remove account
|
|
229
|
+
*/
|
|
230
|
+
app.delete('/api/accounts/:email', async (req, res) => {
|
|
231
|
+
try {
|
|
232
|
+
const { email } = req.params;
|
|
233
|
+
await removeAccount(email);
|
|
234
|
+
|
|
235
|
+
// Reload AccountManager to pick up changes
|
|
236
|
+
await accountManager.reload();
|
|
237
|
+
|
|
238
|
+
res.json({
|
|
239
|
+
status: 'ok',
|
|
240
|
+
message: `Account ${email} removed`
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* POST /api/accounts/reload - Reload accounts from disk
|
|
249
|
+
*/
|
|
250
|
+
app.post('/api/accounts/reload', async (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
// Reload AccountManager from disk
|
|
253
|
+
await accountManager.reload();
|
|
254
|
+
|
|
255
|
+
const status = accountManager.getStatus();
|
|
256
|
+
res.json({
|
|
257
|
+
status: 'ok',
|
|
258
|
+
message: 'Accounts reloaded from disk',
|
|
259
|
+
summary: status.summary
|
|
260
|
+
});
|
|
261
|
+
} catch (error) {
|
|
262
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* GET /api/accounts/export - Export accounts
|
|
268
|
+
*/
|
|
269
|
+
app.get('/api/accounts/export', async (req, res) => {
|
|
270
|
+
try {
|
|
271
|
+
const { accounts } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
|
272
|
+
|
|
273
|
+
// Export only essential fields for portability
|
|
274
|
+
const exportData = accounts
|
|
275
|
+
.filter(acc => acc.source !== 'database')
|
|
276
|
+
.map(acc => {
|
|
277
|
+
const essential = { email: acc.email };
|
|
278
|
+
// Use snake_case for compatibility
|
|
279
|
+
if (acc.refreshToken) {
|
|
280
|
+
essential.refresh_token = acc.refreshToken;
|
|
281
|
+
}
|
|
282
|
+
if (acc.apiKey) {
|
|
283
|
+
essential.api_key = acc.apiKey;
|
|
284
|
+
}
|
|
285
|
+
return essential;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Return plain array for simpler format
|
|
289
|
+
res.json(exportData);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
logger.error('[WebUI] Export accounts error:', error);
|
|
292
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* POST /api/accounts/import - Batch import accounts
|
|
298
|
+
*/
|
|
299
|
+
app.post('/api/accounts/import', async (req, res) => {
|
|
300
|
+
try {
|
|
301
|
+
// Support both wrapped format { accounts: [...] } and plain array [...]
|
|
302
|
+
let importAccounts = req.body;
|
|
303
|
+
if (req.body.accounts && Array.isArray(req.body.accounts)) {
|
|
304
|
+
importAccounts = req.body.accounts;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!Array.isArray(importAccounts) || importAccounts.length === 0) {
|
|
308
|
+
return res.status(400).json({
|
|
309
|
+
status: 'error',
|
|
310
|
+
error: 'accounts must be a non-empty array'
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const results = { added: [], updated: [], failed: [] };
|
|
315
|
+
|
|
316
|
+
// Load existing accounts once before the loop
|
|
317
|
+
const { accounts: existingAccounts } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
|
318
|
+
const existingEmails = new Set(existingAccounts.map(a => a.email));
|
|
319
|
+
|
|
320
|
+
for (const acc of importAccounts) {
|
|
321
|
+
try {
|
|
322
|
+
// Validate required fields
|
|
323
|
+
if (!acc.email) {
|
|
324
|
+
results.failed.push({ email: acc.email || 'unknown', reason: 'Missing email' });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Support both snake_case and camelCase
|
|
329
|
+
const refreshToken = acc.refresh_token || acc.refreshToken;
|
|
330
|
+
const apiKey = acc.api_key || acc.apiKey;
|
|
331
|
+
|
|
332
|
+
// Must have at least one credential
|
|
333
|
+
if (!refreshToken && !apiKey) {
|
|
334
|
+
results.failed.push({ email: acc.email, reason: 'Missing refresh_token or api_key' });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Check if account already exists
|
|
339
|
+
const exists = existingEmails.has(acc.email);
|
|
340
|
+
|
|
341
|
+
// Add account
|
|
342
|
+
await addAccount({
|
|
343
|
+
email: acc.email,
|
|
344
|
+
source: apiKey ? 'manual' : 'oauth',
|
|
345
|
+
refreshToken: refreshToken,
|
|
346
|
+
apiKey: apiKey
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (exists) {
|
|
350
|
+
results.updated.push(acc.email);
|
|
351
|
+
} else {
|
|
352
|
+
results.added.push(acc.email);
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
results.failed.push({ email: acc.email, reason: err.message });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Reload AccountManager
|
|
360
|
+
await accountManager.reload();
|
|
361
|
+
|
|
362
|
+
logger.info(`[WebUI] Import complete: ${results.added.length} added, ${results.updated.length} updated, ${results.failed.length} failed`);
|
|
363
|
+
|
|
364
|
+
res.json({
|
|
365
|
+
status: 'ok',
|
|
366
|
+
results,
|
|
367
|
+
message: `Imported ${results.added.length + results.updated.length} accounts`
|
|
368
|
+
});
|
|
369
|
+
} catch (error) {
|
|
370
|
+
logger.error('[WebUI] Import accounts error:', error);
|
|
371
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ==========================================
|
|
376
|
+
// Provider Management API
|
|
377
|
+
// ==========================================
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* GET /api/providers - List all available authentication providers
|
|
381
|
+
*/
|
|
382
|
+
app.get('/api/providers', (req, res) => {
|
|
383
|
+
try {
|
|
384
|
+
const providers = getAllAuthProviders();
|
|
385
|
+
// Add additional metadata from PROVIDER_CONFIG
|
|
386
|
+
const enrichedProviders = providers.map(p => ({
|
|
387
|
+
...p,
|
|
388
|
+
config: PROVIDER_CONFIG[p.id] || {}
|
|
389
|
+
}));
|
|
390
|
+
|
|
391
|
+
res.json({
|
|
392
|
+
status: 'ok',
|
|
393
|
+
providers: enrichedProviders
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
logger.error('[WebUI] Error getting providers:', error);
|
|
397
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* POST /api/providers/:providerId/validate - Validate credentials for a provider
|
|
403
|
+
*/
|
|
404
|
+
app.post('/api/providers/:providerId/validate', async (req, res) => {
|
|
405
|
+
try {
|
|
406
|
+
const { providerId } = req.params;
|
|
407
|
+
const { email, apiKey, customApiEndpoint } = req.body;
|
|
408
|
+
|
|
409
|
+
if (!email) {
|
|
410
|
+
return res.status(400).json({ status: 'error', error: 'email is required' });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Create temporary account object for validation
|
|
414
|
+
const tempAccount = {
|
|
415
|
+
email,
|
|
416
|
+
apiKey,
|
|
417
|
+
customApiEndpoint,
|
|
418
|
+
provider: providerId
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Get provider and validate
|
|
422
|
+
const provider = getAuthProvider(providerId);
|
|
423
|
+
const result = await provider.validateCredentials(tempAccount);
|
|
424
|
+
|
|
425
|
+
res.json({
|
|
426
|
+
status: 'ok',
|
|
427
|
+
valid: result.valid,
|
|
428
|
+
email: result.email,
|
|
429
|
+
error: result.error || null
|
|
430
|
+
});
|
|
431
|
+
} catch (error) {
|
|
432
|
+
logger.error('[WebUI] Error validating provider credentials:', error);
|
|
433
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* POST /api/accounts/add - Add account with provider
|
|
439
|
+
*/
|
|
440
|
+
app.post('/api/accounts/add', async (req, res) => {
|
|
441
|
+
try {
|
|
442
|
+
const { email, provider, apiKey, customApiEndpoint } = req.body;
|
|
443
|
+
|
|
444
|
+
if (!email) {
|
|
445
|
+
return res.status(400).json({ status: 'error', error: 'email is required' });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!provider) {
|
|
449
|
+
return res.status(400).json({ status: 'error', error: 'provider is required' });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// For non-Google, non-Copilot providers, API key is required
|
|
453
|
+
if (provider !== 'google' && provider !== 'copilot' && !apiKey) {
|
|
454
|
+
return res.status(400).json({ status: 'error', error: 'apiKey is required for this provider' });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Add account
|
|
458
|
+
await addAccount({
|
|
459
|
+
email,
|
|
460
|
+
provider,
|
|
461
|
+
source: provider === 'google' ? 'oauth' : 'manual',
|
|
462
|
+
apiKey,
|
|
463
|
+
customApiEndpoint
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Reload AccountManager
|
|
467
|
+
await accountManager.reload();
|
|
468
|
+
|
|
469
|
+
res.json({
|
|
470
|
+
status: 'ok',
|
|
471
|
+
message: `Account ${email} added with provider ${provider}`
|
|
472
|
+
});
|
|
473
|
+
} catch (error) {
|
|
474
|
+
logger.error('[WebUI] Error adding account:', error);
|
|
475
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ==========================================
|
|
480
|
+
// GitHub Copilot Device Auth API
|
|
481
|
+
// ==========================================
|
|
482
|
+
|
|
483
|
+
// Pending Copilot device auth flows
|
|
484
|
+
const pendingCopilotFlows = new Map();
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* POST /api/copilot/device-auth - Initiate Copilot device authorization
|
|
488
|
+
*/
|
|
489
|
+
app.post('/api/copilot/device-auth', async (req, res) => {
|
|
490
|
+
try {
|
|
491
|
+
const { CopilotProvider } = await import('../providers/copilot.js');
|
|
492
|
+
const deviceData = await CopilotProvider.initiateDeviceAuth();
|
|
493
|
+
|
|
494
|
+
// Store the flow
|
|
495
|
+
const flowId = crypto.randomUUID();
|
|
496
|
+
pendingCopilotFlows.set(flowId, {
|
|
497
|
+
deviceCode: deviceData.device_code,
|
|
498
|
+
interval: deviceData.interval || 5,
|
|
499
|
+
timestamp: Date.now()
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Clean up old flows (> 10 mins)
|
|
503
|
+
const now = Date.now();
|
|
504
|
+
for (const [key, val] of pendingCopilotFlows.entries()) {
|
|
505
|
+
if (now - val.timestamp > 10 * 60 * 1000) {
|
|
506
|
+
pendingCopilotFlows.delete(key);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
res.json({
|
|
511
|
+
status: 'ok',
|
|
512
|
+
flowId,
|
|
513
|
+
verificationUri: deviceData.verification_uri,
|
|
514
|
+
userCode: deviceData.user_code,
|
|
515
|
+
expiresIn: deviceData.expires_in,
|
|
516
|
+
interval: deviceData.interval || 5
|
|
517
|
+
});
|
|
518
|
+
} catch (error) {
|
|
519
|
+
logger.error('[WebUI] Copilot device auth error:', error);
|
|
520
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* POST /api/copilot/poll-token - Poll for Copilot token after user authorizes
|
|
526
|
+
*/
|
|
527
|
+
app.post('/api/copilot/poll-token', async (req, res) => {
|
|
528
|
+
try {
|
|
529
|
+
const { flowId } = req.body;
|
|
530
|
+
if (!flowId) {
|
|
531
|
+
return res.status(400).json({ status: 'error', error: 'flowId is required' });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const flow = pendingCopilotFlows.get(flowId);
|
|
535
|
+
if (!flow) {
|
|
536
|
+
return res.status(400).json({ status: 'error', error: 'Flow not found or expired' });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const { CopilotProvider } = await import('../providers/copilot.js');
|
|
540
|
+
|
|
541
|
+
// Single poll attempt (client will retry)
|
|
542
|
+
const response = await fetch('https://github.com/login/oauth/access_token', {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: {
|
|
545
|
+
'Accept': 'application/json',
|
|
546
|
+
'Content-Type': 'application/json',
|
|
547
|
+
'User-Agent': 'commons-proxy/2.0.0'
|
|
548
|
+
},
|
|
549
|
+
body: JSON.stringify({
|
|
550
|
+
client_id: 'Iv1.b507a08c87ecfe98',
|
|
551
|
+
device_code: flow.deviceCode,
|
|
552
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
|
553
|
+
})
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const data = await response.json();
|
|
557
|
+
|
|
558
|
+
if (data.access_token) {
|
|
559
|
+
// Got the token! Get user info and add account
|
|
560
|
+
const userInfo = await CopilotProvider.getUserInfo(data.access_token);
|
|
561
|
+
|
|
562
|
+
await addAccount({
|
|
563
|
+
email: userInfo.email,
|
|
564
|
+
provider: 'copilot',
|
|
565
|
+
source: 'manual',
|
|
566
|
+
apiKey: data.access_token
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
await accountManager.reload();
|
|
570
|
+
pendingCopilotFlows.delete(flowId);
|
|
571
|
+
|
|
572
|
+
logger.success(`[WebUI] Copilot account ${userInfo.email} added via device auth`);
|
|
573
|
+
|
|
574
|
+
res.json({
|
|
575
|
+
status: 'ok',
|
|
576
|
+
completed: true,
|
|
577
|
+
email: userInfo.email,
|
|
578
|
+
message: `Account ${userInfo.email} added successfully`
|
|
579
|
+
});
|
|
580
|
+
} else if (data.error === 'authorization_pending') {
|
|
581
|
+
res.json({ status: 'ok', completed: false, pending: true });
|
|
582
|
+
} else if (data.error === 'slow_down') {
|
|
583
|
+
flow.interval = (flow.interval || 5) + 5;
|
|
584
|
+
res.json({ status: 'ok', completed: false, pending: true, interval: flow.interval });
|
|
585
|
+
} else if (data.error === 'expired_token') {
|
|
586
|
+
pendingCopilotFlows.delete(flowId);
|
|
587
|
+
res.json({ status: 'error', error: 'Device code expired. Please try again.' });
|
|
588
|
+
} else if (data.error) {
|
|
589
|
+
pendingCopilotFlows.delete(flowId);
|
|
590
|
+
res.json({ status: 'error', error: data.error_description || data.error });
|
|
591
|
+
} else {
|
|
592
|
+
res.json({ status: 'ok', completed: false, pending: true });
|
|
593
|
+
}
|
|
594
|
+
} catch (error) {
|
|
595
|
+
logger.error('[WebUI] Copilot poll token error:', error);
|
|
596
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// ==========================================
|
|
601
|
+
// Configuration API
|
|
602
|
+
// ==========================================
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* GET /api/config - Get server configuration
|
|
606
|
+
*/
|
|
607
|
+
app.get('/api/config', (req, res) => {
|
|
608
|
+
try {
|
|
609
|
+
const publicConfig = getPublicConfig();
|
|
610
|
+
res.json({
|
|
611
|
+
status: 'ok',
|
|
612
|
+
config: publicConfig,
|
|
613
|
+
version: packageVersion,
|
|
614
|
+
note: 'Edit ~/.config/commons-proxy/config.json or use env vars to change these values'
|
|
615
|
+
});
|
|
616
|
+
} catch (error) {
|
|
617
|
+
logger.error('[WebUI] Error getting config:', error);
|
|
618
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* POST /api/config - Update server configuration
|
|
624
|
+
*/
|
|
625
|
+
app.post('/api/config', (req, res) => {
|
|
626
|
+
try {
|
|
627
|
+
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries } = req.body;
|
|
628
|
+
|
|
629
|
+
// Only allow updating specific fields (security)
|
|
630
|
+
const updates = {};
|
|
631
|
+
if (typeof debug === 'boolean') updates.debug = debug;
|
|
632
|
+
if (logLevel && ['info', 'warn', 'error', 'debug'].includes(logLevel)) {
|
|
633
|
+
updates.logLevel = logLevel;
|
|
634
|
+
}
|
|
635
|
+
if (typeof maxRetries === 'number' && maxRetries >= 1 && maxRetries <= 20) {
|
|
636
|
+
updates.maxRetries = maxRetries;
|
|
637
|
+
}
|
|
638
|
+
if (typeof retryBaseMs === 'number' && retryBaseMs >= 100 && retryBaseMs <= 10000) {
|
|
639
|
+
updates.retryBaseMs = retryBaseMs;
|
|
640
|
+
}
|
|
641
|
+
if (typeof retryMaxMs === 'number' && retryMaxMs >= 1000 && retryMaxMs <= 120000) {
|
|
642
|
+
updates.retryMaxMs = retryMaxMs;
|
|
643
|
+
}
|
|
644
|
+
if (typeof persistTokenCache === 'boolean') {
|
|
645
|
+
updates.persistTokenCache = persistTokenCache;
|
|
646
|
+
}
|
|
647
|
+
if (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) {
|
|
648
|
+
updates.defaultCooldownMs = defaultCooldownMs;
|
|
649
|
+
}
|
|
650
|
+
if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
|
|
651
|
+
updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
|
|
652
|
+
}
|
|
653
|
+
if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
|
|
654
|
+
updates.maxAccounts = maxAccounts;
|
|
655
|
+
}
|
|
656
|
+
if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) {
|
|
657
|
+
updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs;
|
|
658
|
+
}
|
|
659
|
+
if (typeof maxConsecutiveFailures === 'number' && maxConsecutiveFailures >= 1 && maxConsecutiveFailures <= 10) {
|
|
660
|
+
updates.maxConsecutiveFailures = maxConsecutiveFailures;
|
|
661
|
+
}
|
|
662
|
+
if (typeof extendedCooldownMs === 'number' && extendedCooldownMs >= 10000 && extendedCooldownMs <= 300000) {
|
|
663
|
+
updates.extendedCooldownMs = extendedCooldownMs;
|
|
664
|
+
}
|
|
665
|
+
if (typeof maxCapacityRetries === 'number' && maxCapacityRetries >= 1 && maxCapacityRetries <= 10) {
|
|
666
|
+
updates.maxCapacityRetries = maxCapacityRetries;
|
|
667
|
+
}
|
|
668
|
+
// Account selection strategy validation
|
|
669
|
+
if (accountSelection && typeof accountSelection === 'object') {
|
|
670
|
+
const validStrategies = ['sticky', 'round-robin', 'hybrid'];
|
|
671
|
+
if (accountSelection.strategy && validStrategies.includes(accountSelection.strategy)) {
|
|
672
|
+
updates.accountSelection = {
|
|
673
|
+
...(config.accountSelection || {}),
|
|
674
|
+
strategy: accountSelection.strategy
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (Object.keys(updates).length === 0) {
|
|
680
|
+
return res.status(400).json({
|
|
681
|
+
status: 'error',
|
|
682
|
+
error: 'No valid configuration updates provided'
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const success = saveConfig(updates);
|
|
687
|
+
|
|
688
|
+
if (success) {
|
|
689
|
+
res.json({
|
|
690
|
+
status: 'ok',
|
|
691
|
+
message: 'Configuration saved. Restart server to apply some changes.',
|
|
692
|
+
updates: updates,
|
|
693
|
+
config: getPublicConfig()
|
|
694
|
+
});
|
|
695
|
+
} else {
|
|
696
|
+
res.status(500).json({
|
|
697
|
+
status: 'error',
|
|
698
|
+
error: 'Failed to save configuration file'
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
} catch (error) {
|
|
702
|
+
logger.error('[WebUI] Error updating config:', error);
|
|
703
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* POST /api/config/password - Change WebUI password
|
|
709
|
+
*/
|
|
710
|
+
app.post('/api/config/password', (req, res) => {
|
|
711
|
+
try {
|
|
712
|
+
const { oldPassword, newPassword } = req.body;
|
|
713
|
+
|
|
714
|
+
// Validate input
|
|
715
|
+
if (!newPassword || typeof newPassword !== 'string') {
|
|
716
|
+
return res.status(400).json({
|
|
717
|
+
status: 'error',
|
|
718
|
+
error: 'New password is required'
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// If current password exists, verify old password
|
|
723
|
+
if (config.webuiPassword && config.webuiPassword !== oldPassword) {
|
|
724
|
+
return res.status(403).json({
|
|
725
|
+
status: 'error',
|
|
726
|
+
error: 'Invalid current password'
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Save new password
|
|
731
|
+
const success = saveConfig({ webuiPassword: newPassword });
|
|
732
|
+
|
|
733
|
+
if (success) {
|
|
734
|
+
// Update in-memory config
|
|
735
|
+
config.webuiPassword = newPassword;
|
|
736
|
+
res.json({
|
|
737
|
+
status: 'ok',
|
|
738
|
+
message: 'Password changed successfully'
|
|
739
|
+
});
|
|
740
|
+
} else {
|
|
741
|
+
throw new Error('Failed to save password to config file');
|
|
742
|
+
}
|
|
743
|
+
} catch (error) {
|
|
744
|
+
logger.error('[WebUI] Error changing password:', error);
|
|
745
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* GET /api/settings - Get runtime settings
|
|
751
|
+
*/
|
|
752
|
+
app.get('/api/settings', async (req, res) => {
|
|
753
|
+
try {
|
|
754
|
+
const settings = accountManager.getSettings ? accountManager.getSettings() : {};
|
|
755
|
+
res.json({
|
|
756
|
+
status: 'ok',
|
|
757
|
+
settings: {
|
|
758
|
+
...settings,
|
|
759
|
+
port: process.env.PORT || DEFAULT_PORT
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
} catch (error) {
|
|
763
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// ==========================================
|
|
768
|
+
// Claude CLI Configuration API
|
|
769
|
+
// ==========================================
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* GET /api/claude/config - Get Claude CLI configuration
|
|
773
|
+
*/
|
|
774
|
+
app.get('/api/claude/config', async (req, res) => {
|
|
775
|
+
try {
|
|
776
|
+
const claudeConfig = await readClaudeConfig();
|
|
777
|
+
res.json({
|
|
778
|
+
status: 'ok',
|
|
779
|
+
config: claudeConfig,
|
|
780
|
+
path: getClaudeConfigPath()
|
|
781
|
+
});
|
|
782
|
+
} catch (error) {
|
|
783
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* POST /api/claude/config - Update Claude CLI configuration
|
|
789
|
+
*/
|
|
790
|
+
app.post('/api/claude/config', async (req, res) => {
|
|
791
|
+
try {
|
|
792
|
+
const updates = req.body;
|
|
793
|
+
if (!updates || typeof updates !== 'object') {
|
|
794
|
+
return res.status(400).json({ status: 'error', error: 'Invalid config updates' });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const newConfig = await updateClaudeConfig(updates);
|
|
798
|
+
res.json({
|
|
799
|
+
status: 'ok',
|
|
800
|
+
config: newConfig,
|
|
801
|
+
message: 'Claude configuration updated'
|
|
802
|
+
});
|
|
803
|
+
} catch (error) {
|
|
804
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* POST /api/claude/config/restore - Restore Claude CLI to default (remove proxy settings)
|
|
810
|
+
*/
|
|
811
|
+
app.post('/api/claude/config/restore', async (req, res) => {
|
|
812
|
+
try {
|
|
813
|
+
const claudeConfig = await readClaudeConfig();
|
|
814
|
+
|
|
815
|
+
// Proxy-related environment variables to remove when restoring defaults
|
|
816
|
+
const PROXY_ENV_VARS = [
|
|
817
|
+
'ANTHROPIC_BASE_URL',
|
|
818
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
819
|
+
'ANTHROPIC_MODEL',
|
|
820
|
+
'CLAUDE_CODE_SUBAGENT_MODEL',
|
|
821
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
822
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
823
|
+
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
824
|
+
'ENABLE_EXPERIMENTAL_MCP_CLI'
|
|
825
|
+
];
|
|
826
|
+
|
|
827
|
+
// Remove proxy-related environment variables to restore defaults
|
|
828
|
+
if (claudeConfig.env) {
|
|
829
|
+
for (const key of PROXY_ENV_VARS) {
|
|
830
|
+
delete claudeConfig.env[key];
|
|
831
|
+
}
|
|
832
|
+
// Remove env entirely if empty to truly restore defaults
|
|
833
|
+
if (Object.keys(claudeConfig.env).length === 0) {
|
|
834
|
+
delete claudeConfig.env;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Use replaceClaudeConfig to completely overwrite the config (not merge)
|
|
839
|
+
const newConfig = await replaceClaudeConfig(claudeConfig);
|
|
840
|
+
|
|
841
|
+
logger.info(`[WebUI] Restored Claude CLI config to defaults at ${getClaudeConfigPath()}`);
|
|
842
|
+
|
|
843
|
+
res.json({
|
|
844
|
+
status: 'ok',
|
|
845
|
+
config: newConfig,
|
|
846
|
+
message: 'Claude CLI configuration restored to defaults'
|
|
847
|
+
});
|
|
848
|
+
} catch (error) {
|
|
849
|
+
logger.error('[WebUI] Error restoring Claude config:', error);
|
|
850
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// ==========================================
|
|
855
|
+
// Claude CLI Presets API
|
|
856
|
+
// ==========================================
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* GET /api/claude/presets - Get all saved presets
|
|
860
|
+
*/
|
|
861
|
+
app.get('/api/claude/presets', async (req, res) => {
|
|
862
|
+
try {
|
|
863
|
+
const presets = await readPresets();
|
|
864
|
+
res.json({ status: 'ok', presets });
|
|
865
|
+
} catch (error) {
|
|
866
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* POST /api/claude/presets - Save a new preset
|
|
872
|
+
*/
|
|
873
|
+
app.post('/api/claude/presets', async (req, res) => {
|
|
874
|
+
try {
|
|
875
|
+
const { name, config: presetConfig } = req.body;
|
|
876
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
877
|
+
return res.status(400).json({ status: 'error', error: 'Preset name is required' });
|
|
878
|
+
}
|
|
879
|
+
if (!presetConfig || typeof presetConfig !== 'object') {
|
|
880
|
+
return res.status(400).json({ status: 'error', error: 'Config object is required' });
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const presets = await savePreset(name.trim(), presetConfig);
|
|
884
|
+
res.json({ status: 'ok', presets, message: `Preset "${name}" saved` });
|
|
885
|
+
} catch (error) {
|
|
886
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* DELETE /api/claude/presets/:name - Delete a preset
|
|
892
|
+
*/
|
|
893
|
+
app.delete('/api/claude/presets/:name', async (req, res) => {
|
|
894
|
+
try {
|
|
895
|
+
const { name } = req.params;
|
|
896
|
+
if (!name) {
|
|
897
|
+
return res.status(400).json({ status: 'error', error: 'Preset name is required' });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const presets = await deletePreset(name);
|
|
901
|
+
res.json({ status: 'ok', presets, message: `Preset "${name}" deleted` });
|
|
902
|
+
} catch (error) {
|
|
903
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* POST /api/models/config - Update model configuration (hidden/pinned/alias)
|
|
909
|
+
*/
|
|
910
|
+
app.post('/api/models/config', (req, res) => {
|
|
911
|
+
try {
|
|
912
|
+
const { modelId, config: newModelConfig } = req.body;
|
|
913
|
+
|
|
914
|
+
if (!modelId || typeof newModelConfig !== 'object') {
|
|
915
|
+
return res.status(400).json({ status: 'error', error: 'Invalid parameters' });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Load current config
|
|
919
|
+
const currentMapping = config.modelMapping || {};
|
|
920
|
+
|
|
921
|
+
// Update specific model config
|
|
922
|
+
currentMapping[modelId] = {
|
|
923
|
+
...currentMapping[modelId],
|
|
924
|
+
...newModelConfig
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
// Save back to main config
|
|
928
|
+
const success = saveConfig({ modelMapping: currentMapping });
|
|
929
|
+
|
|
930
|
+
if (success) {
|
|
931
|
+
// Update in-memory config reference
|
|
932
|
+
config.modelMapping = currentMapping;
|
|
933
|
+
res.json({ status: 'ok', modelConfig: currentMapping[modelId] });
|
|
934
|
+
} else {
|
|
935
|
+
throw new Error('Failed to save configuration');
|
|
936
|
+
}
|
|
937
|
+
} catch (error) {
|
|
938
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
// ==========================================
|
|
943
|
+
// Logs API
|
|
944
|
+
// ==========================================
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* GET /api/logs - Get log history
|
|
948
|
+
*/
|
|
949
|
+
app.get('/api/logs', (req, res) => {
|
|
950
|
+
res.json({
|
|
951
|
+
status: 'ok',
|
|
952
|
+
logs: logger.getHistory ? logger.getHistory() : []
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* GET /api/logs/stream - Stream logs via SSE
|
|
958
|
+
*/
|
|
959
|
+
app.get('/api/logs/stream', (req, res) => {
|
|
960
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
961
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
962
|
+
res.setHeader('Connection', 'keep-alive');
|
|
963
|
+
|
|
964
|
+
const sendLog = (log) => {
|
|
965
|
+
res.write(`data: ${JSON.stringify(log)}\n\n`);
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
// Send recent history if requested
|
|
969
|
+
if (req.query.history === 'true' && logger.getHistory) {
|
|
970
|
+
const history = logger.getHistory();
|
|
971
|
+
history.forEach(log => sendLog(log));
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Subscribe to new logs
|
|
975
|
+
if (logger.on) {
|
|
976
|
+
logger.on('log', sendLog);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Cleanup on disconnect
|
|
980
|
+
req.on('close', () => {
|
|
981
|
+
if (logger.off) {
|
|
982
|
+
logger.off('log', sendLog);
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// ==========================================
|
|
988
|
+
// OAuth API
|
|
989
|
+
// ==========================================
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* GET /api/auth/url - Get OAuth URL to start the flow
|
|
993
|
+
* Uses CLI's OAuth flow (localhost:51121) instead of WebUI's port
|
|
994
|
+
* to match Google OAuth Console's authorized redirect URIs
|
|
995
|
+
*/
|
|
996
|
+
app.get('/api/auth/url', async (req, res) => {
|
|
997
|
+
try {
|
|
998
|
+
// Clean up old flows (> 10 mins)
|
|
999
|
+
const now = Date.now();
|
|
1000
|
+
for (const [key, val] of pendingOAuthFlows.entries()) {
|
|
1001
|
+
if (now - val.timestamp > 10 * 60 * 1000) {
|
|
1002
|
+
pendingOAuthFlows.delete(key);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Generate OAuth URL using default redirect URI (localhost:51121)
|
|
1007
|
+
const { url, verifier, state } = getAuthorizationUrl();
|
|
1008
|
+
|
|
1009
|
+
// Start callback server on port 51121 (same as CLI)
|
|
1010
|
+
const { promise: serverPromise, abort: abortServer } = startCallbackServer(state, 120000); // 2 min timeout
|
|
1011
|
+
|
|
1012
|
+
// Store the flow data
|
|
1013
|
+
pendingOAuthFlows.set(state, {
|
|
1014
|
+
serverPromise,
|
|
1015
|
+
abortServer,
|
|
1016
|
+
verifier,
|
|
1017
|
+
state,
|
|
1018
|
+
timestamp: Date.now()
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// Start async handler for the OAuth callback
|
|
1022
|
+
serverPromise
|
|
1023
|
+
.then(async (code) => {
|
|
1024
|
+
try {
|
|
1025
|
+
logger.info('[WebUI] Received OAuth callback, completing flow...');
|
|
1026
|
+
const accountData = await completeOAuthFlow(code, verifier);
|
|
1027
|
+
|
|
1028
|
+
// Add or update the account
|
|
1029
|
+
// Note: Don't set projectId here - it will be discovered and stored
|
|
1030
|
+
// in the refresh token via getProjectForAccount() on first use
|
|
1031
|
+
await addAccount({
|
|
1032
|
+
email: accountData.email,
|
|
1033
|
+
refreshToken: accountData.refreshToken,
|
|
1034
|
+
source: 'oauth'
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// Reload AccountManager to pick up the new account
|
|
1038
|
+
await accountManager.reload();
|
|
1039
|
+
|
|
1040
|
+
logger.success(`[WebUI] Account ${accountData.email} added successfully`);
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
logger.error('[WebUI] OAuth flow completion error:', err);
|
|
1043
|
+
} finally {
|
|
1044
|
+
pendingOAuthFlows.delete(state);
|
|
1045
|
+
}
|
|
1046
|
+
})
|
|
1047
|
+
.catch((err) => {
|
|
1048
|
+
// Only log if not aborted (manual completion causes this)
|
|
1049
|
+
if (!err.message?.includes('aborted')) {
|
|
1050
|
+
logger.error('[WebUI] OAuth callback server error:', err);
|
|
1051
|
+
}
|
|
1052
|
+
pendingOAuthFlows.delete(state);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
res.json({ status: 'ok', url, state });
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
logger.error('[WebUI] Error generating auth URL:', error);
|
|
1058
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* POST /api/auth/complete - Complete OAuth with manually submitted callback URL/code
|
|
1064
|
+
* Used when auto-callback cannot reach the local server
|
|
1065
|
+
*/
|
|
1066
|
+
app.post('/api/auth/complete', async (req, res) => {
|
|
1067
|
+
try {
|
|
1068
|
+
const { callbackInput, state } = req.body;
|
|
1069
|
+
|
|
1070
|
+
if (!callbackInput || !state) {
|
|
1071
|
+
return res.status(400).json({
|
|
1072
|
+
status: 'error',
|
|
1073
|
+
error: 'Missing callbackInput or state'
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Find the pending flow
|
|
1078
|
+
const flowData = pendingOAuthFlows.get(state);
|
|
1079
|
+
if (!flowData) {
|
|
1080
|
+
return res.status(400).json({
|
|
1081
|
+
status: 'error',
|
|
1082
|
+
error: 'OAuth flow not found. The account may have been already added via auto-callback. Please refresh the account list.'
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const { verifier, abortServer } = flowData;
|
|
1087
|
+
|
|
1088
|
+
// Extract code from input (URL or raw code)
|
|
1089
|
+
const { extractCodeFromInput, completeOAuthFlow } = await import('../auth/oauth.js');
|
|
1090
|
+
const { code } = extractCodeFromInput(callbackInput);
|
|
1091
|
+
|
|
1092
|
+
// Complete the OAuth flow
|
|
1093
|
+
const accountData = await completeOAuthFlow(code, verifier);
|
|
1094
|
+
|
|
1095
|
+
// Add or update the account
|
|
1096
|
+
await addAccount({
|
|
1097
|
+
email: accountData.email,
|
|
1098
|
+
refreshToken: accountData.refreshToken,
|
|
1099
|
+
projectId: accountData.projectId,
|
|
1100
|
+
source: 'oauth'
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// Reload AccountManager to pick up the new account
|
|
1104
|
+
await accountManager.reload();
|
|
1105
|
+
|
|
1106
|
+
// Abort the callback server since manual completion succeeded
|
|
1107
|
+
if (abortServer) {
|
|
1108
|
+
abortServer();
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Clean up
|
|
1112
|
+
pendingOAuthFlows.delete(state);
|
|
1113
|
+
|
|
1114
|
+
logger.success(`[WebUI] Account ${accountData.email} added via manual callback`);
|
|
1115
|
+
|
|
1116
|
+
res.json({
|
|
1117
|
+
status: 'ok',
|
|
1118
|
+
email: accountData.email,
|
|
1119
|
+
message: `Account ${accountData.email} added successfully`
|
|
1120
|
+
});
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
logger.error('[WebUI] Manual OAuth completion error:', error);
|
|
1123
|
+
res.status(500).json({ status: 'error', error: error.message });
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Note: /oauth/callback route removed
|
|
1129
|
+
* OAuth callbacks are now handled by the temporary server on port 51121
|
|
1130
|
+
* (same as CLI) to match Google OAuth Console's authorized redirect URIs
|
|
1131
|
+
*/
|
|
1132
|
+
|
|
1133
|
+
logger.info('[WebUI] Mounted at /');
|
|
1134
|
+
}
|