Blame Raw
Cohee · 51ad27fb · · 644 lines (19.1 KB)
3 contributors
1import fs from 'node:fs';
2import path from 'node:path';
3
4import express from 'express';
5import { sync as writeFileAtomicSync } from 'write-file-atomic';
6import { color, getConfigValue, uuidv4 } from '../util.js';
7
8export const SECRETS_FILE = 'secrets.json';
9export const SECRET_KEYS = {
10 _MIGRATED: '_migrated',
11 HORDE: 'api_key_horde',
12 MANCER: 'api_key_mancer',
13 VLLM: 'api_key_vllm',
14 APHRODITE: 'api_key_aphrodite',
15 TABBY: 'api_key_tabby',
16 OPENAI: 'api_key_openai',
17 NOVEL: 'api_key_novel',
18 CLAUDE: 'api_key_claude',
19 DEEPL: 'deepl',
20 LIBRE: 'libre',
21 LIBRE_URL: 'libre_url',
22 LINGVA_URL: 'lingva_url',
23 OPENROUTER: 'api_key_openrouter',
24 AI21: 'api_key_ai21',
25 ONERING_URL: 'oneringtranslator_url',
26 DEEPLX_URL: 'deeplx_url',
27 MAKERSUITE: 'api_key_makersuite',
28 VERTEXAI: 'api_key_vertexai',
29 SERPAPI: 'api_key_serpapi',
30 TOGETHERAI: 'api_key_togetherai',
31 MISTRALAI: 'api_key_mistralai',
32 CUSTOM: 'api_key_custom',
33 OOBA: 'api_key_ooba',
34 INFERMATICAI: 'api_key_infermaticai',
35 DREAMGEN: 'api_key_dreamgen',
36 NOMICAI: 'api_key_nomicai',
37 KOBOLDCPP: 'api_key_koboldcpp',
38 LLAMACPP: 'api_key_llamacpp',
39 COHERE: 'api_key_cohere',
40 PERPLEXITY: 'api_key_perplexity',
41 GROQ: 'api_key_groq',
42 AZURE_TTS: 'api_key_azure_tts',
43 FEATHERLESS: 'api_key_featherless',
44 HUGGINGFACE: 'api_key_huggingface',
45 STABILITY: 'api_key_stability',
46 CUSTOM_OPENAI_TTS: 'api_key_custom_openai_tts',
47 TAVILY: 'api_key_tavily',
48 CHUTES: 'api_key_chutes',
49 ELECTRONHUB: 'api_key_electronhub',
50 NANOGPT: 'api_key_nanogpt',
51 BFL: 'api_key_bfl',
52 COMFY_RUNPOD: 'api_key_comfy_runpod',
53 FALAI: 'api_key_falai',
54 GENERIC: 'api_key_generic',
55 DEEPSEEK: 'api_key_deepseek',
56 SERPER: 'api_key_serper',
57 AIMLAPI: 'api_key_aimlapi',
58 XAI: 'api_key_xai',
59 FIREWORKS: 'api_key_fireworks',
60 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
61 MINIMAX: 'api_key_minimax',
62 MINIMAX_GROUP_ID: 'minimax_group_id',
63 MOONSHOT: 'api_key_moonshot',
64 COMETAPI: 'api_key_cometapi',
65 AZURE_OPENAI: 'api_key_azure_openai',
66 ZAI: 'api_key_zai',
67 SILICONFLOW: 'api_key_siliconflow',
68 ELEVENLABS: 'api_key_elevenlabs',
69 POLLINATIONS: 'api_key_pollinations',
70 VOLCENGINE_APP_ID: 'volcengine_app_id',
71 VOLCENGINE_ACCESS_KEY: 'volcengine_access_key',
72 WORKERS_AI: 'api_key_workers_ai',
73};
74
75/**
76 * @typedef {object} SecretValue
77 * @property {string} id The unique identifier for the secret
78 * @property {string} value The secret value
79 * @property {string} label The label for the secret
80 * @property {boolean} active Whether the secret is currently active
81 */
82
83/**
84 * @typedef {object} SecretState
85 * @property {string} id The unique identifier for the secret
86 * @property {string} value The secret value, masked for security
87 * @property {string} label The label for the secret
88 * @property {boolean} active Whether the secret is currently active
89 */
90
91/**
92 * @typedef {Record<string, SecretState[]|null>} SecretStateMap
93 */
94
95/**
96 * @typedef {{[key: string]: SecretValue[]}} SecretKeys
97 * @typedef {{[key: string]: string}} FlatSecretKeys
98 */
99
100// These are the keys that are safe to expose, even if allowKeysExposure is false
101const EXPORTABLE_KEYS = [
102 SECRET_KEYS.LIBRE_URL,
103 SECRET_KEYS.LINGVA_URL,
104 SECRET_KEYS.ONERING_URL,
105 SECRET_KEYS.DEEPLX_URL,
106];
107
108export const allowKeysExposure = !!getConfigValue('allowKeysExposure', false, 'boolean');
109
110/**
111 * SecretManager class to handle all secret operations
112 */
113export class SecretManager {
114 /**
115 * @param {import('../users.js').UserDirectoryList} directories
116 */
117 constructor(directories) {
118 this.directories = directories;
119 this.filePath = path.join(directories.root, SECRETS_FILE);
120 this.defaultSecrets = {};
121 }
122
123 /**
124 * Ensures the secrets file exists, creating an empty one if necessary
125 * @private
126 */
127 _ensureSecretsFile() {
128 if (!fs.existsSync(this.filePath)) {
129 writeFileAtomicSync(this.filePath, JSON.stringify(this.defaultSecrets), 'utf-8');
130 }
131 }
132
133 /**
134 * Reads and parses the secrets file
135 * @private
136 * @returns {SecretKeys}
137 */
138 _readSecretsFile() {
139 this._ensureSecretsFile();
140 const fileContents = fs.readFileSync(this.filePath, 'utf-8');
141 return /** @type {SecretKeys} */ (JSON.parse(fileContents));
142 }
143
144 /**
145 * Writes secrets to the file atomically
146 * @private
147 * @param {SecretKeys} secrets
148 */
149 _writeSecretsFile(secrets) {
150 writeFileAtomicSync(this.filePath, JSON.stringify(secrets, null, 4), 'utf-8');
151 }
152
153 /**
154 * Deactivates all secrets for a given key
155 * @private
156 * @param {SecretValue[]} secretArray
157 */
158 _deactivateAllSecrets(secretArray) {
159 secretArray.forEach(secret => {
160 secret.active = false;
161 });
162 }
163
164 /**
165 * Validates that the secret key exists and has valid structure
166 * @private
167 * @param {SecretKeys} secrets
168 * @param {string} key
169 * @returns {boolean}
170 */
171 _validateSecretKey(secrets, key) {
172 return Object.hasOwn(secrets, key) && Array.isArray(secrets[key]);
173 }
174
175 /**
176 * Masks a secret value with asterisks in the middle
177 * @param {string} value The secret value to mask
178 * @param {string} key The secret key
179 * @returns {string} A masked version of the value for peeking
180 */
181 getMaskedValue(value, key) {
182 // No masking if exposure is allowed
183 if (allowKeysExposure || EXPORTABLE_KEYS.includes(key)) {
184 return value;
185 }
186 const threshold = 10;
187 const exposedChars = 3;
188 const placeholder = '*';
189 if (value.length <= threshold) {
190 return placeholder.repeat(threshold);
191 }
192 const visibleEnd = value.slice(-exposedChars);
193 const maskedMiddle = placeholder.repeat(threshold - exposedChars);
194 return `${maskedMiddle}${visibleEnd}`;
195 }
196
197 /**
198 * Writes a secret to the secrets file
199 * @param {string} key Secret key
200 * @param {string} value Secret value
201 * @param {string} label Label for the secret
202 * @returns {string} The ID of the newly created secret
203 */
204 writeSecret(key, value, label = 'Unlabeled') {
205 const secrets = this._readSecretsFile();
206
207 if (!Array.isArray(secrets[key])) {
208 secrets[key] = [];
209 }
210
211 this._deactivateAllSecrets(secrets[key]);
212
213 const secret = {
214 id: uuidv4(),
215 value: value,
216 label: label,
217 active: true,
218 };
219 secrets[key].push(secret);
220
221 this._writeSecretsFile(secrets);
222 return secret.id;
223 }
224
225 /**
226 * Deletes a secret from the secrets file by its ID
227 * @param {string} key Secret key
228 * @param {string?} id Secret ID to delete
229 */
230 deleteSecret(key, id) {
231 if (!fs.existsSync(this.filePath)) {
232 return;
233 }
234
235 const secrets = this._readSecretsFile();
236
237 if (!this._validateSecretKey(secrets, key)) {
238 return;
239 }
240
241 const secretArray = secrets[key];
242 const targetIndex = secretArray.findIndex(s => id ? s.id === id : s.active);
243
244 // Delete the secret if found
245 if (targetIndex !== -1) {
246 secretArray.splice(targetIndex, 1);
247 }
248
249 // Reactivate the first secret if none are active
250 if (secretArray.length && !secretArray.some(s => s.active)) {
251 secretArray[0].active = true;
252 }
253
254 // Remove the key if no secrets left
255 if (secretArray.length === 0) {
256 delete secrets[key];
257 }
258
259 this._writeSecretsFile(secrets);
260 }
261
262 /**
263 * Reads the active secret value for a given key
264 * @param {string} key Secret key
265 * @param {string?} id ID of the secret to read (optional)
266 * @returns {string} Secret value or empty string if not found
267 */
268 readSecret(key, id) {
269 if (!fs.existsSync(this.filePath)) {
270 return '';
271 }
272
273 const secrets = this._readSecretsFile();
274 const secretArray = secrets[key];
275
276 if (Array.isArray(secretArray) && secretArray.length > 0) {
277 const activeSecret = secretArray.find(s => id ? s.id === id : s.active);
278 return activeSecret?.value || '';
279 }
280
281 return '';
282 }
283
284 /**
285 * Activates a specific secret by ID for a given key
286 * @param {string} key Secret key to rotate
287 * @param {string} id ID of the secret to activate
288 */
289 rotateSecret(key, id) {
290 if (!fs.existsSync(this.filePath)) {
291 return;
292 }
293
294 const secrets = this._readSecretsFile();
295
296 if (!this._validateSecretKey(secrets, key)) {
297 return;
298 }
299
300 const secretArray = secrets[key];
301 const targetIndex = secretArray.findIndex(s => s.id === id);
302
303 if (targetIndex === -1) {
304 console.warn(`Secret with ID ${id} not found for key ${key}`);
305 return;
306 }
307
308 this._deactivateAllSecrets(secretArray);
309 secretArray[targetIndex].active = true;
310
311 this._writeSecretsFile(secrets);
312 }
313
314 /**
315 * Renames a secret by its ID
316 * @param {string} key Secret key to rename
317 * @param {string} id ID of the secret to rename
318 * @param {string} label New label for the secret
319 */
320 renameSecret(key, id, label) {
321 const secrets = this._readSecretsFile();
322
323 if (!this._validateSecretKey(secrets, key)) {
324 return;
325 }
326
327 const secretArray = secrets[key];
328 const targetIndex = secretArray.findIndex(s => s.id === id);
329
330 if (targetIndex === -1) {
331 console.warn(`Secret with ID ${id} not found for key ${key}`);
332 return;
333 }
334
335 secretArray[targetIndex].label = label;
336 this._writeSecretsFile(secrets);
337 }
338
339 /**
340 * Gets the state of all secrets (whether they exist or not)
341 * @returns {SecretStateMap} Secret state
342 */
343 getSecretState() {
344 const secrets = this._readSecretsFile();
345 /** @type {SecretStateMap} */
346 const state = {};
347
348 for (const key of Object.values(SECRET_KEYS)) {
349 // Skip migration marker
350 if (key === SECRET_KEYS._MIGRATED) {
351 continue;
352 }
353 const value = secrets[key];
354 if (value && Array.isArray(value) && value.length > 0) {
355 state[key] = value.map(secret => ({
356 id: secret.id,
357 value: this.getMaskedValue(secret.value, key),
358 label: secret.label,
359 active: secret.active,
360 }));
361 } else {
362 // No secrets for this key
363 state[key] = null;
364 }
365 }
366
367 return state;
368 }
369
370 /**
371 * Gets all secrets (for admin viewing)
372 * @returns {SecretKeys} All secrets
373 */
374 getAllSecrets() {
375 return this._readSecretsFile();
376 }
377
378 /**
379 * Migrates legacy flat secrets format to new format
380 */
381 migrateFlatSecrets() {
382 if (!fs.existsSync(this.filePath)) {
383 return;
384 }
385
386 const fileContents = fs.readFileSync(this.filePath, 'utf8');
387 const secrets = /** @type {FlatSecretKeys} */ (JSON.parse(fileContents));
388 const values = Object.values(secrets);
389
390 // Check if already migrated
391 if (secrets[SECRET_KEYS._MIGRATED] || values.length === 0 || values.some(v => Array.isArray(v))) {
392 return;
393 }
394
395 /** @type {SecretKeys} */
396 const migratedSecrets = {};
397
398 for (const [key, value] of Object.entries(secrets)) {
399 if (typeof value === 'string' && value.trim()) {
400 migratedSecrets[key] = [{
401 id: uuidv4(),
402 value: value,
403 label: key,
404 active: true,
405 }];
406 }
407 }
408
409 // Mark as migrated
410 migratedSecrets[SECRET_KEYS._MIGRATED] = [];
411
412 // Save backup of the old secrets file
413 const backupFilePath = path.join(this.directories.backups, `secrets_migration_${Date.now()}.json`);
414 fs.cpSync(this.filePath, backupFilePath);
415
416 this._writeSecretsFile(migratedSecrets);
417 console.info(color.green('Secrets migrated successfully, old secrets backed up to:'), backupFilePath);
418 }
419}
420
421//#region Backwards compatibility
422/**
423 * Writes a secret to the secrets file
424 * @param {import('../users.js').UserDirectoryList} directories User directories
425 * @param {string} key Secret key
426 * @param {string} value Secret value
427 */
428export function writeSecret(directories, key, value) {
429 return new SecretManager(directories).writeSecret(key, value);
430}
431
432/**
433 * Deletes a secret from the secrets file
434 * @param {import('../users.js').UserDirectoryList} directories User directories
435 * @param {string} key Secret key
436 */
437export function deleteSecret(directories, key) {
438 return new SecretManager(directories).deleteSecret(key, null);
439}
440
441/**
442 * Reads a secret from the secrets file
443 * @param {import('../users.js').UserDirectoryList} directories User directories
444 * @param {string} key Secret key
445 * @param {string?} id Secret ID (optional)
446 * @returns {string} Secret value
447 */
448export function readSecret(directories, key, id = null) {
449 return new SecretManager(directories).readSecret(key, id);
450}
451
452/**
453 * Reads the secret state from the secrets file
454 * @param {import('../users.js').UserDirectoryList} directories User directories
455 * @returns {Record<string, boolean>} Secret state
456 */
457export function readSecretState(directories) {
458 const state = new SecretManager(directories).getSecretState();
459 const result = /** @type {Record<string, boolean>} */ ({});
460 for (const key of Object.values(SECRET_KEYS)) {
461 // Skip migration marker
462 if (key === SECRET_KEYS._MIGRATED) {
463 continue;
464 }
465 result[key] = Array.isArray(state[key]) && state[key].length > 0;
466 }
467 return result;
468}
469
470/**
471 * Reads all secrets from the secrets file
472 * @param {import('../users.js').UserDirectoryList} directories User directories
473 * @returns {Record<string, string>} Secrets
474 */
475export function getAllSecrets(directories) {
476 const secrets = new SecretManager(directories).getAllSecrets();
477 const result = /** @type {Record<string, string>} */ ({});
478 for (const [key, values] of Object.entries(secrets)) {
479 // Skip migration marker
480 if (key === SECRET_KEYS._MIGRATED) {
481 continue;
482 }
483 if (Array.isArray(values) && values.length > 0) {
484 const activeSecret = values.find(secret => secret.active);
485 if (activeSecret) {
486 result[key] = activeSecret.value;
487 }
488 }
489 }
490 return result;
491}
492//#endregion
493
494/**
495 * Migrates legacy flat secrets format to the new format for all user directories
496 * @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
497 */
498export function migrateFlatSecrets(directoriesList) {
499 for (const directories of directoriesList) {
500 try {
501 const manager = new SecretManager(directories);
502 manager.migrateFlatSecrets();
503 } catch (error) {
504 console.warn(color.red(`Failed to migrate secrets for ${directories.root}:`), error);
505 }
506 }
507}
508
509export const router = express.Router();
510
511router.post('/write', (request, response) => {
512 try {
513 const { key, value, label } = request.body;
514
515 if (!key || typeof value !== 'string') {
516 return response.status(400).send('Invalid key or value');
517 }
518
519 const manager = new SecretManager(request.user.directories);
520 const id = manager.writeSecret(key, value, label);
521
522 return response.send({ id });
523 } catch (error) {
524 console.error('Error writing secret:', error);
525 return response.sendStatus(500);
526 }
527});
528
529router.post('/read', (request, response) => {
530 try {
531 const manager = new SecretManager(request.user.directories);
532 const state = manager.getSecretState();
533 return response.send(state);
534 } catch (error) {
535 console.error('Error reading secret state:', error);
536 return response.send({});
537 }
538});
539
540router.post('/view', (request, response) => {
541 try {
542 if (!allowKeysExposure) {
543 console.error('secrets.json could not be viewed unless allowKeysExposure in config.yaml is set to true');
544 return response.sendStatus(403);
545 }
546
547 const secrets = getAllSecrets(request.user.directories);
548
549 if (!secrets) {
550 return response.sendStatus(404);
551 }
552
553 return response.send(secrets);
554 } catch (error) {
555 console.error('Error viewing secrets:', error);
556 return response.sendStatus(500);
557 }
558});
559
560router.post('/find', (request, response) => {
561 try {
562 const { key, id } = request.body;
563
564 if (!key) {
565 return response.status(400).send('Key is required');
566 }
567
568 if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
569 console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
570 return response.sendStatus(403);
571 }
572
573 const manager = new SecretManager(request.user.directories);
574 const state = manager.getSecretState();
575
576 if (!state[key]) {
577 return response.sendStatus(404);
578 }
579
580 const secretValue = manager.readSecret(key, id);
581 return response.send({ value: secretValue });
582 } catch (error) {
583 console.error('Error finding secret:', error);
584 return response.sendStatus(500);
585 }
586});
587
588router.post('/delete', (request, response) => {
589 try {
590 const { key, id } = request.body;
591
592 if (!key) {
593 return response.status(400).send('Key and ID are required');
594 }
595
596 const manager = new SecretManager(request.user.directories);
597 manager.deleteSecret(key, id);
598
599 return response.sendStatus(204);
600 } catch (error) {
601 console.error('Error deleting secret:', error);
602 return response.sendStatus(500);
603 }
604});
605
606router.post('/rotate', (request, response) => {
607 try {
608 const { key, id } = request.body;
609
610 if (!key || !id) {
611 return response.status(400).send('Key and ID are required');
612 }
613
614 const manager = new SecretManager(request.user.directories);
615 manager.rotateSecret(key, id);
616
617 return response.sendStatus(204);
618 } catch (error) {
619 console.error('Error rotating secret:', error);
620 return response.sendStatus(500);
621 }
622});
623
624router.post('/rename', (request, response) => {
625 try {
626 const { key, id, label } = request.body;
627
628 if (!key || !id || !label) {
629 return response.status(400).send('Key, ID, and label are required');
630 }
631
632 const manager = new SecretManager(request.user.directories);
633 manager.renameSecret(key, id, label);
634
635 return response.sendStatus(204);
636 } catch (error) {
637 console.error('Error renaming secret:', error);
638 return response.sendStatus(500);
639 }
640});
641
642router.post('/settings', async (_request, response) => {
643 return response.send({ allowKeysExposure });
644});