- For privacy reasons, your API key will be hidden after you reload the page.
+
+ For privacy reasons, your API key will be hidden after you click 'Connect'.
Cohere Model
@@ -3578,10 +3571,10 @@
-
+
-
- For privacy reasons, your API key will be hidden after you reload the page.
+
+ For privacy reasons, your API key will be hidden after you click 'Connect'.
Enter a Model ID
@@ -3601,10 +3594,10 @@
-
+
-
- For privacy reasons, your API key will be hidden after you reload the page.
+
+ For privacy reasons, your API key will be hidden after you click 'Connect'.
01.AI Model
@@ -7471,6 +7466,9 @@
+
+
+
diff --git a/public/script.js b/public/script.js
index 858b1edac..b98870e0c 100644
--- a/public/script.js
+++ b/public/script.js
@@ -208,6 +208,7 @@ import {
} from './scripts/tags.js';
import {
SECRET_KEYS,
+ initSecrets,
readSecretState,
secret_state,
writeSecret,
@@ -975,6 +976,7 @@ async function firstLoadInit() {
reloadMarkdownProcessor();
applyBrowserFixes();
await getClientVersion();
+ await initSecrets();
await readSecretState();
await initLocales();
initChatUtilities();
diff --git a/public/scripts/extensions/connection-manager/index.js b/public/scripts/extensions/connection-manager/index.js
index 34c79205a..40cdfafb6 100644
--- a/public/scripts/extensions/connection-manager/index.js
+++ b/public/scripts/extensions/connection-manager/index.js
@@ -13,6 +13,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js';
import { t } from '../../i18n.js';
+import { getSecretLabelById } from '../../secrets.js';
const MODULE_NAME = 'connection-manager';
const NONE = '
';
@@ -41,6 +42,7 @@ const CC_COMMANDS = [
'start-reply-with',
'reasoning-template',
'prompt-post-processing',
+ 'secret-id',
];
const TC_COMMANDS = [
@@ -57,6 +59,7 @@ const TC_COMMANDS = [
'stop-strings',
'start-reply-with',
'reasoning-template',
+ 'secret-id',
];
const FANCY_NAMES = {
@@ -75,6 +78,7 @@ const FANCY_NAMES = {
'start-reply-with': 'Start Reply With',
'reasoning-template': 'Reasoning Template',
'prompt-post-processing': 'Prompt Post-Processing',
+ 'secret-id': 'Secret',
};
/**
@@ -344,6 +348,15 @@ function makeFancyProfile(profile) {
return acc;
}
+ // UUID is not very useful in the UI, so we replace it with a label (if available)
+ if (key === 'secret-id') {
+ const label = getSecretLabelById(profile[key]);
+ if (label) {
+ acc[value] = label;
+ return acc;
+ }
+ }
+
acc[value] = profile[key];
return acc;
}, {});
diff --git a/public/scripts/horde.js b/public/scripts/horde.js
index e62a0864b..07baecd3d 100644
--- a/public/scripts/horde.js
+++ b/public/scripts/horde.js
@@ -394,10 +394,11 @@ function getHordeModelTemplate(option) {
`));
}
-export function initHorde () {
+export function initHorde() {
$('#horde_model').on('mousedown change', async function (e) {
console.log('Horde model change', e);
- horde_settings.models = $('#horde_model').val();
+ const modelValue = $('#horde_model').val();
+ horde_settings.models = Array.isArray(modelValue) ? modelValue : [];
console.log('Updated Horde models', horde_settings.models);
// Try select instruct preset
@@ -429,8 +430,12 @@ export function initHorde () {
saveSettingsDebounced();
});
- $('#horde_api_key').on('input', async function () {
- const key = String($(this).val()).trim();
+ $('#horde_api_key_button').on('click', async function () {
+ const key = String($('#horde_api_key').val()).trim();
+ if (!key) {
+ toastr.warning(t`Please enter your Horde API key`);
+ return;
+ }
await writeSecret(SECRET_KEYS.HORDE, key);
});
diff --git a/public/scripts/openai.js b/public/scripts/openai.js
index 7176404b7..67c2f4c68 100644
--- a/public/scripts/openai.js
+++ b/public/scripts/openai.js
@@ -56,6 +56,7 @@ import {
getSortableDelay,
getStringHash,
isDataURL,
+ isUuid,
isValidUrl,
parseJsonFile,
resetScrollHeight,
@@ -5670,7 +5671,8 @@ async function onVertexAIValidateServiceAccount() {
}
// Save to backend secret storage
- await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent);
+ const keyLabel = serviceAccount['client_email'] || '';
+ await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent, keyLabel);
// Show success status
updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`);
@@ -5704,6 +5706,11 @@ async function onVertexAIClearServiceAccount() {
function onVertexAIServiceAccountJsonChange() {
const jsonContent = String($(this).val()).trim();
+ // Autocomplete has been triggered, don't validate if the input is a UUID
+ if (isUuid(jsonContent)) {
+ return;
+ }
+
if (jsonContent) {
// Auto-validate when content is pasted
try {
diff --git a/public/scripts/popup.js b/public/scripts/popup.js
index bc45a20c3..9b047fc77 100644
--- a/public/scripts/popup.js
+++ b/public/scripts/popup.js
@@ -53,6 +53,7 @@ export const POPUP_RESULT = {
* @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
* @property {(popup: Popup) => Promise|boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close
* @property {(popup: Popup) => Promise|void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up
+ * @property {(popup: Popup) => Promise|void?} [onOpen=null] - Handler called after the popup opens
* @property {number?} [cropAspect=null] - Aspect ratio for the crop popup
* @property {string?} [cropImage=null] - Image URL to display in the crop popup
*/
@@ -155,6 +156,7 @@ export class Popup {
/** @type {(popup: Popup) => Promise|boolean?} */ onClosing;
/** @type {(popup: Popup) => Promise|void?} */ onClose;
+ /** @type {(popup: Popup) => Promise|void?} */ onOpen;
/** @type {POPUP_RESULT|number} */ result;
/** @type {any} */ value;
@@ -175,7 +177,7 @@ export class Popup {
* @param {string} [inputValue=''] - The initial value of the input field
* @param {PopupOptions} [options={}] - Additional options for the popup
*/
- constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, leftAlign = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
+ constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, leftAlign = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, onOpen = null, cropAspect = null, cropImage = null } = {}) {
Popup.util.popups.push(this);
// Make this popup uniquely identifiable
@@ -185,6 +187,7 @@ export class Popup {
// Utilize event handlers being passed in
this.onClosing = onClosing;
this.onClose = onClose;
+ this.onOpen = onOpen;
/**@type {HTMLTemplateElement}*/
const template = document.querySelector('#popup_template');
@@ -478,6 +481,11 @@ export class Popup {
runAfterAnimation(this.dlg, () => {
this.dlg.removeAttribute('opening');
+
+ // If we have an onOpen handler, we run it now
+ if (this.onOpen) {
+ this.onOpen(this);
+ }
});
this.#promise = new Promise((resolve) => {
diff --git a/public/scripts/secrets.js b/public/scripts/secrets.js
index 861a5daac..4e1bade93 100644
--- a/public/scripts/secrets.js
+++ b/public/scripts/secrets.js
@@ -1,7 +1,18 @@
-import { DOMPurify } from '../lib.js';
+import { DOMPurify, moment } from '../lib.js';
import { getRequestHeaders } from '../script.js';
import { t } from './i18n.js';
+import { chat_completion_sources } from './openai.js';
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
+import { SlashCommand } from './slash-commands/SlashCommand.js';
+import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
+import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
+import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
+import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
+import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
+import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
+import { renderTemplateAsync } from './templates.js';
+import { textgen_types } from './textgen-settings.js';
+import { copyText, isTrueBoolean } from './utils.js';
export const SECRET_KEYS = {
HORDE: 'api_key_horde',
@@ -48,6 +59,51 @@ export const SECRET_KEYS = {
VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
};
+const FRIENDLY_NAMES = {
+ [SECRET_KEYS.HORDE]: 'AI Horde',
+ [SECRET_KEYS.MANCER]: 'Mancer',
+ [SECRET_KEYS.OPENAI]: 'OpenAI',
+ [SECRET_KEYS.NOVEL]: 'NovelAI',
+ [SECRET_KEYS.CLAUDE]: 'Claude',
+ [SECRET_KEYS.OPENROUTER]: 'OpenRouter',
+ [SECRET_KEYS.SCALE]: 'Scale',
+ [SECRET_KEYS.AI21]: 'AI21',
+ [SECRET_KEYS.SCALE_COOKIE]: 'Scale (Cookie)',
+ [SECRET_KEYS.MAKERSUITE]: 'Google AI Studio',
+ [SECRET_KEYS.VERTEXAI]: 'Google Vertex AI (Express Mode)',
+ [SECRET_KEYS.VLLM]: 'vLLM',
+ [SECRET_KEYS.APHRODITE]: 'Aphrodite',
+ [SECRET_KEYS.TABBY]: 'TabbyAPI',
+ [SECRET_KEYS.MISTRALAI]: 'MistralAI',
+ [SECRET_KEYS.CUSTOM]: 'Custom (OpenAI-compatible)',
+ [SECRET_KEYS.TOGETHERAI]: 'TogetherAI',
+ [SECRET_KEYS.OOBA]: 'Text Generation WebUI',
+ [SECRET_KEYS.INFERMATICAI]: 'InfermaticAI',
+ [SECRET_KEYS.DREAMGEN]: 'DreamGen',
+ [SECRET_KEYS.NOMICAI]: 'NomicAI',
+ [SECRET_KEYS.KOBOLDCPP]: 'KoboldCpp',
+ [SECRET_KEYS.LLAMACPP]: 'llama.cpp',
+ [SECRET_KEYS.COHERE]: 'Cohere',
+ [SECRET_KEYS.PERPLEXITY]: 'Perplexity',
+ [SECRET_KEYS.GROQ]: 'Groq',
+ [SECRET_KEYS.FEATHERLESS]: 'Featherless',
+ [SECRET_KEYS.ZEROONEAI]: '01.AI',
+ [SECRET_KEYS.HUGGINGFACE]: 'HuggingFace',
+ [SECRET_KEYS.NANOGPT]: 'NanoGPT',
+ [SECRET_KEYS.GENERIC]: 'Generic (OpenAI-compatible)',
+ [SECRET_KEYS.DEEPSEEK]: 'DeepSeek',
+ [SECRET_KEYS.XAI]: 'xAI (Grok)',
+ [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: 'Google Vertex AI (Service Account)',
+ [SECRET_KEYS.STABILITY]: 'Stability AI',
+ [SECRET_KEYS.CUSTOM_OPENAI_TTS]: 'Custom OpenAI TTS',
+ [SECRET_KEYS.TAVILY]: 'Tavily',
+ [SECRET_KEYS.BFL]: 'Black Forest Labs',
+ [SECRET_KEYS.SERPAPI]: 'SerpApi',
+ [SECRET_KEYS.SERPER]: 'Serper',
+ [SECRET_KEYS.FALAI]: 'FAL.AI',
+ [SECRET_KEYS.AZURE_TTS]: 'Azure TTS',
+};
+
const INPUT_MAP = {
[SECRET_KEYS.HORDE]: '#horde_api_key',
[SECRET_KEYS.MANCER]: '#api_key_mancer',
@@ -85,31 +141,103 @@ const INPUT_MAP = {
[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: '#vertexai_service_account_json',
};
-const STATIC_PLACEHOLDER_KEYS = [
- SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT,
-];
+const getLabel = () => moment().format('L LT');
-async function clearSecret() {
- const key = $(this).data('key');
- await writeSecret(key, '');
- secret_state[key] = false;
- updateSecretDisplay();
- $(INPUT_MAP[key]).val('').trigger('input');
- $('#main_api').trigger('change');
+/**
+ * Resolves the secret key based on the selected API, chat completion source, and text completion type.
+ * @returns {string|null} The secret key corresponding to the selected API, or null if no key is found.
+ */
+function resolveSecretKey() {
+ const { mainApi, chatCompletionSettings, textCompletionSettings } = SillyTavern.getContext();
+ const chatCompletionSource = chatCompletionSettings.chat_completion_source;
+ const textCompletionType = textCompletionSettings.type;
+
+ if (mainApi === 'koboldhorde') {
+ return SECRET_KEYS.HORDE;
+ }
+
+ if (mainApi === 'novel') {
+ return SECRET_KEYS.NOVEL;
+ }
+
+ if (mainApi === 'textgenerationwebui') {
+ const [key] = Object.entries(textgen_types).find(([, value]) => value === textCompletionType) ?? [null];
+ if (key && SECRET_KEYS[key]) {
+ return SECRET_KEYS[key];
+ }
+ }
+
+ if (mainApi === 'openai') {
+ if (chatCompletionSource === chat_completion_sources.VERTEXAI) {
+ switch (chatCompletionSettings.vertexai_auth_mode) {
+ case 'express':
+ return SECRET_KEYS.VERTEXAI;
+ case 'full':
+ return SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT;
+ }
+ }
+
+ if (chatCompletionSource === chat_completion_sources.SCALE) {
+ return chatCompletionSettings.use_alt_scale
+ ? SECRET_KEYS.SCALE_COOKIE
+ : SECRET_KEYS.SCALE;
+ }
+
+ const [key] = Object.entries(chat_completion_sources).find(([, value]) => value === chatCompletionSource) ?? [null];
+ if (key && SECRET_KEYS[key]) {
+ return SECRET_KEYS[key];
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Gets the label of a secret by its ID.
+ * @param {string} id The ID of the secret to find.
+ * @returns {string} The label of the secret with the given ID, or an empty string if not found.
+ */
+export function getSecretLabelById(id) {
+ for (const key of Object.values(SECRET_KEYS)) {
+ const secrets = secret_state[key];
+ if (!Array.isArray(secrets)) {
+ continue;
+ }
+ const secret = secrets.find(s => s.id === id);
+ if (secret) {
+ return `${secret.label} (${secret.value})`;
+ }
+ }
+ return '';
}
export function updateSecretDisplay() {
for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
- if (STATIC_PLACEHOLDER_KEYS.includes(secret_key)) {
- continue;
- }
const validSecret = !!secret_state[secret_key];
-
const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');
- $(input_selector).attr('placeholder', placeholder);
+ const label = getActiveSecretLabel(secret_key);
+ const placeholderWithLabel = label ? `${placeholder} (${label})` : placeholder;
+ $(input_selector).attr('placeholder', placeholderWithLabel);
}
}
+/**
+ * Gets the active secret label for a given key.
+ * @param {string} key Gets the active secret label for a given key.
+ * @returns {string} The label of the active secret, or '[No label]' if none is active.
+ */
+function getActiveSecretLabel(key) {
+ const selectedSecret = secret_state[key];
+ if (Array.isArray(selectedSecret)) {
+ const activeSecret = selectedSecret.find(x => x.active);
+ if (!activeSecret) {
+ return '';
+ }
+ return activeSecret.label || activeSecret.value || t`[No label]`;
+ }
+ return '';
+}
+
async function viewSecrets() {
const response = await fetch('/api/secrets/view', {
method: 'POST',
@@ -125,7 +253,6 @@ async function viewSecrets() {
return;
}
- $('#dialogue_popup').addClass('wide_dialogue_popup');
const data = await response.json();
const table = document.createElement('table');
table.classList.add('responsiveTable');
@@ -138,29 +265,78 @@ async function viewSecrets() {
await callGenericPopup(table.outerHTML, POPUP_TYPE.TEXT, '', { wide: true, large: true, allowVerticalScrolling: true });
}
+/**
+ * @type {import('../../src/endpoints/secrets.js').SecretStateMap}
+ */
export let secret_state = {};
-export async function writeSecret(key, value) {
+/**
+ * Write a secret value to the server.
+ * @param {string} key Secret key
+ * @param {string} value Secret value to write
+ * @param {string} [label] (Optional) Label for the key. If not provided, generated automatically.
+ * @return {Promise} The ID of the newly created secret key, or null if no value is provided.
+ */
+export async function writeSecret(key, value, label) {
try {
+ if (!value) {
+ console.warn(`No value provided for ${key} in writeSecret, redirecting to deleteSecret`);
+ await deleteSecret(key);
+ return null;
+ }
+
+ if (!label) {
+ label = getLabel();
+ }
+
const response = await fetch('/api/secrets/write', {
method: 'POST',
headers: getRequestHeaders(),
- body: JSON.stringify({ key, value }),
+ body: JSON.stringify({ key, value, label }),
});
- if (response.ok) {
- const text = await response.text();
-
- if (text == 'ok') {
- secret_state[key] = !!value;
- updateSecretDisplay();
- }
+ if (!response.ok) {
+ return null;
}
- } catch {
- console.error('Could not write secret value: ', key);
+
+ const { id } = await response.json();
+ // Clear the input field
+ $(INPUT_MAP[key]).val('').trigger('input');
+ await readSecretState();
+ return id;
+ } catch (error) {
+ console.error(`Could not write secret value: ${key}`, error);
+ return null;
}
}
+/**
+ * Deletes a secret value from the server.
+ * @param {string} key Secret key
+ * @param {string} [id] (Optional) ID of the secret key to delete. If not provided, deletes an active key.
+ */
+export async function deleteSecret(key, id) {
+ try {
+ const response = await fetch('/api/secrets/delete', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({ key, id }),
+ });
+
+ if (response.ok) {
+ await readSecretState();
+ // Force reconnection to the API with the new key
+ $('#main_api').trigger('change');
+ }
+ } catch (error) {
+ console.error(`Could not delete secret value: ${key}`, error);
+ }
+}
+
+/**
+ * Reads the current state of secrets from the server.
+ * @returns {Promise}
+ */
export async function readSecretState() {
try {
const response = await fetch('/api/secrets/read', {
@@ -171,6 +347,7 @@ export async function readSecretState() {
if (response.ok) {
secret_state = await response.json();
updateSecretDisplay();
+ updateInputDataLists();
await checkOpenRouterAuth();
}
} catch {
@@ -181,31 +358,87 @@ export async function readSecretState() {
/**
* Finds a secret value by key.
* @param {string} key Secret key
- * @returns {Promise} Secret value, or undefined if keys are not exposed
+ * @param {string} [id] ID of the secret to find. If not provided, will return the active secret.
+ * @returns {Promise} Secret value, or null if keys are not exposed
*/
-export async function findSecret(key) {
+export async function findSecret(key, id) {
try {
const response = await fetch('/api/secrets/find', {
method: 'POST',
headers: getRequestHeaders(),
- body: JSON.stringify({ key }),
+ body: JSON.stringify({ key, id }),
});
- if (response.ok) {
- const data = await response.json();
- return data.value;
+ if (!response.ok) {
+ return null;
}
+
+ const data = await response.json();
+ return data.value;
} catch {
console.error('Could not find secret value: ', key);
+ return null;
}
}
+/**
+ * Changes the active value for a given secret key.
+ * @param {string} key Secret key to rotate
+ * @param {string} id ID of the secret to rotate
+ */
+export async function rotateSecret(key, id) {
+ try {
+ const response = await fetch('/api/secrets/rotate', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({ key, id }),
+ });
+
+ if (response.ok) {
+ await readSecretState();
+ // Force reconnection to the API with the new key
+ $('#main_api').trigger('change');
+ }
+ } catch (error) {
+ console.error(`Could not rotate secret value: ${key}`, error);
+ }
+}
+
+/**
+ * Renames a secret value on the server.
+ * @param {string} key Secret key to rename
+ * @param {string} id ID of the secret to rename
+ * @param {string} label Label to rename the secret to
+ */
+export async function renameSecret(key, id, label) {
+ try {
+ const response = await fetch('/api/secrets/rename', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({ key, id, label }),
+ });
+
+ if (response.ok) {
+ await readSecretState();
+ }
+ } catch (error) {
+ console.error(`Could not rename secret value: ${key}`, error);
+ }
+}
+
+/**
+ * Redirects the user to authorize OpenRouter.
+ */
function authorizeOpenRouter() {
const redirectUrl = new URL('/callback/openrouter', window.location.origin);
const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}`;
location.href = openRouterUrl;
}
+/**
+ * Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key.
+ * @returns {Promise}
+ */
async function checkOpenRouterAuth() {
const params = new URLSearchParams(location.search);
const source = params.get('source');
@@ -245,14 +478,554 @@ async function checkOpenRouterAuth() {
}
}
-jQuery(async () => {
+/**
+ * Updates the input data lists for secret keys for autocomplete functionality.
+ */
+function updateInputDataLists() {
+ let container = document.getElementById('secrets_datalists');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'secrets_datalists';
+ container.style.display = 'none';
+ document.body.appendChild(container);
+ }
+
+ for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
+ const inputElements = document.querySelectorAll(inputSelector);
+ if (inputElements.length === 0) {
+ console.warn(`No input elements found for key: ${key}`);
+ continue;
+ }
+
+ const dataListId = `${key}_datalist`;
+ let dataList = document.getElementById(dataListId);
+ if (!dataList) {
+ dataList = document.createElement('datalist');
+ dataList.id = dataListId;
+ container.appendChild(dataList);
+ }
+
+ // Clear existing options
+ dataList.innerHTML = '';
+
+ const secrets = secret_state[key];
+ if (!Array.isArray(secrets)) {
+ continue;
+ }
+
+ for (const secret of secrets) {
+ const option = document.createElement('option');
+ option.value = secret.id;
+ option.textContent = `${secret.label} (${secret.value})`;
+ dataList.appendChild(option);
+ }
+
+ // Set the input element to use the datalist
+ inputElements.forEach(element => {
+ element.setAttribute('list', dataListId);
+ });
+ }
+}
+
+/**
+ * Opens the key manager dialog for a specific key.
+ * @param {string} key Key for which to open the key manager dialog.
+ */
+async function openKeyManagerDialog(key) {
+ const name = FRIENDLY_NAMES[key] || key;
+ const template = $(await renderTemplateAsync('secretKeyManager', { name, key }));
+ template.find('button[data-action="add-secret"]').on('click', async function () {
+ let label = '';
+ const value = await Popup.show.input(t`Add Secret`, t`Enter the secret value:`, '', {
+ customInputs: [{
+ id: 'newSecretLabel',
+ type: 'text',
+ label: t`Enter a label for the secret (optional):`,
+ }],
+ onClose: popup => {
+ if (popup.result) {
+ label = popup.inputResults.get('newSecretLabel').toString().trim();
+ }
+ },
+ });
+ if (!value) {
+ return;
+ }
+ await writeSecret(key, value, label);
+ await renderSecretsList();
+ });
+
+ await renderSecretsList();
+ await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, onOpen: scrollToActive });
+
+ async function renderSecretsList() {
+ const secrets = secret_state[key] ?? [];
+ const list = template.find('.secretKeyManagerList');
+ const previousScrollTop = list.scrollTop();
+
+ const emptyMessage = template.find('.secretKeyManagerListEmpty');
+ emptyMessage.toggle(secrets.length === 0);
+
+ const itemBlocks = [];
+ for (const secret of secrets) {
+ const itemTemplate = $(await renderTemplateAsync('secretKeyManagerListItem', secret));
+ itemTemplate.find('[data-action="copy-id"]').on('click', async function () {
+ await copyText(secret.id);
+ toastr.info(t`Secret ID copied to clipboard.`);
+ });
+ itemTemplate.find('button[data-action="rotate-secret"]').on('click', async function () {
+ await rotateSecret(key, secret.id);
+ await renderSecretsList();
+ });
+ itemTemplate.find('button[data-action="copy-secret"]').on('click', async function () {
+ const secretValue = await findSecret(key, secret.id);
+ if (secretValue === null) {
+ toastr.error(t`The key exposure might be disabled by the server config.`, t`Failed to copy secret value`);
+ return;
+ }
+ await copyText(secretValue);
+ toastr.info(t`Secret value copied to clipboard.`);
+ });
+ itemTemplate.find('button[data-action="rename-secret"]').on('click', async function () {
+ const label = await Popup.show.input(t`Rename Secret`, t`Enter new label for the secret:`, secret?.label || getLabel());
+ if (!label) {
+ return;
+ }
+ await renameSecret(key, secret.id, label);
+ await renderSecretsList();
+ });
+ itemTemplate.find('button[data-action="delete-secret"]').on('click', async function () {
+ const confirm = await Popup.show.confirm(t`Delete Secret: ${secret?.label}`, t`Are you sure you want to delete this secret? This action cannot be undone.`);
+ if (!confirm) {
+ return;
+ }
+ await deleteSecret(key, secret.id);
+ await renderSecretsList();
+ });
+ itemBlocks.push(itemTemplate);
+ }
+
+ list.empty().append(itemBlocks).scrollTop(previousScrollTop);
+ }
+
+ function scrollToActive() {
+ const list = template.find('.secretKeyManagerList');
+ const activeKey = list.find('.active');
+ if (activeKey.length > 0) {
+ const activeKeyScrollTop = activeKey.position().top + list.scrollTop() - list.height() / 2;
+ list.scrollTop(activeKeyScrollTop);
+ }
+ }
+}
+
+function registerSecretSlashCommands() {
+ const secretKeyEnumProvider = () => Object.values(SECRET_KEYS).map(key => new SlashCommandEnumValue(key, FRIENDLY_NAMES[key] || key, enumTypes.name, enumIcons.key));
+ const secretIdEnumProvider = (/** @type {SlashCommandExecutor} */ executor, /** @type {SlashCommandScope} */ _scope) => {
+ const key = executor?.namedArgumentList?.find(x => x.name === 'key')?.value?.toString() || resolveSecretKey();
+ if (!key || !secret_state[key] || !Array.isArray(secret_state[key]) || secret_state[key].length === 0) {
+ return [];
+ }
+
+ return secret_state[key].map(secret => {
+ return new SlashCommandEnumValue(secret.id, `${secret.label} (${secret.value})`, enumTypes.name, enumIcons.key);
+ });
+ };
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'secret-id',
+ aliases: ['secret-rotate'],
+ helpString: t`Sets the ID of a currently active secret key. Gets the ID of the secret key if no value is provided.`,
+ returns: t`The ID of the secret key that is now active.`,
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'quiet',
+ description: t`Suppress toast message notifications.`,
+ isRequired: false,
+ defaultValue: String(false),
+ typeList: [ARGUMENT_TYPE.BOOLEAN],
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'key',
+ description: t`The key to get the secret ID for. If not provided, will use the currently active API secrets.`,
+ isRequired: false,
+ typeList: [ARGUMENT_TYPE.STRING],
+ enumProvider: secretKeyEnumProvider,
+ }),
+ ],
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: t`The ID or a label of the secret key to set as active. If not provided, will return the currently active secret ID.`,
+ isRequired: true,
+ typeList: [ARGUMENT_TYPE.STRING],
+ enumProvider: secretIdEnumProvider,
+ }),
+ ],
+ callback: async (args, value) => {
+ const quiet = isTrueBoolean(args?.quiet?.toString());
+ const id = value?.toString()?.trim();
+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
+
+ if (!key) {
+ if (!quiet) {
+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
+ }
+ return '';
+ }
+
+ const secrets = secret_state[key];
+ if (!Array.isArray(secrets) || secrets.length === 0) {
+ if (!quiet) {
+ toastr.error(t`No saved secrets found for the key: ${key}`);
+ }
+ return '';
+ }
+
+ if (!id) {
+ const activeSecret = secrets.find(s => s.active);
+ if (!activeSecret) {
+ if (!quiet) {
+ toastr.error(t`No active secret found for the key: ${key}`);
+ }
+ return '';
+ }
+ return activeSecret.id;
+ }
+
+ const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id);
+ if (!savedSecret) {
+ if (!quiet) {
+ toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
+ }
+ return '';
+ }
+
+ // Set the secret as active
+ await rotateSecret(key, savedSecret.id);
+ if (!quiet) {
+ toastr.success(t`Secret with ID: ${id} is now active for the key: ${key}`);
+ }
+
+ return savedSecret.id;
+ },
+ }));
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'secret-delete',
+ helpString: t`Deletes a secret key by ID.`,
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'quiet',
+ description: t`Suppress toast message notifications.`,
+ isRequired: false,
+ defaultValue: String(false),
+ typeList: [ARGUMENT_TYPE.BOOLEAN],
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'key',
+ description: t`The key to delete the secret from. If not provided, will use the currently active API secrets.`,
+ isRequired: false,
+ typeList: [ARGUMENT_TYPE.STRING],
+ enumProvider: secretKeyEnumProvider,
+ }),
+ ],
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: t`The ID or a label of the secret key to delete. If not provided, will delete the active secret.`,
+ isRequired: true,
+ typeList: [ARGUMENT_TYPE.STRING],
+ enumProvider: secretIdEnumProvider,
+ }),
+ ],
+ callback: async (args, value) => {
+ const quiet = isTrueBoolean(args?.quiet?.toString());
+ const id = value?.toString()?.trim();
+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
+
+ if (!key) {
+ if (!quiet) {
+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
+ }
+ return '';
+ }
+
+ const secrets = secret_state[key];
+ if (!Array.isArray(secrets) || secrets.length === 0) {
+ if (!quiet) {
+ toastr.error(t`No saved secrets found for the key: ${key}`);
+ }
+ return '';
+ }
+
+ const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
+ if (!savedSecret) {
+ if (!quiet) {
+ toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
+ }
+ return '';
+ }
+
+ // Delete the secret
+ await deleteSecret(key, savedSecret.id);
+ if (!quiet) {
+ toastr.success(t`Secret with ID: ${id} has been deleted for the key: ${key}`);
+ }
+
+ return savedSecret.id;
+ },
+ }));
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'secret-write',
+ helpString: t`Writes a secret key with a value and an optional label.`,
+ returns: t`The ID of the newly created secret key.`,
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'quiet',
+ description: t`Suppress toast message notifications.`,
+ isRequired: false,
+ defaultValue: String(false),
+ typeList: [ARGUMENT_TYPE.BOOLEAN],
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'key',
+ description: t`The key to write the secret to. If not provided, will use the currently active API secrets.`,
+ isRequired: false,
+ typeList: [ARGUMENT_TYPE.STRING],
+ enumProvider: secretKeyEnumProvider,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'label',
+ description: t`The label for the secret key. If not provided, will use the current date and time.`,
+ isRequired: false,
+ typeList: [ARGUMENT_TYPE.STRING],
+ }),
+ ],
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: t`The value of the secret key to write.`,
+ isRequired: true,
+ typeList: [ARGUMENT_TYPE.STRING],
+ }),
+ ],
+ callback: async (args, value) => {
+ const quiet = isTrueBoolean(args?.quiet?.toString());
+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
+
+ if (!key) {
+ if (!quiet) {
+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
+ }
+ return '';
+ }
+
+ const secrets = secret_state[key];
+ if (!Array.isArray(secrets) || secrets.length === 0) {
+ if (!quiet) {
+ toastr.error(t`No saved secrets found for the key: ${key}`);
+ }
+ return '';
+ }
+
+ const valueStr = value?.toString()?.trim();
+ if (!valueStr) {
+ if (!quiet) {
+ toastr.error(t`No value provided for the secret key: ${key}`);
+ }
+ return '';
+ }
+
+ const label = args?.label?.toString()?.trim() || getLabel();
+ const id = await writeSecret(key, valueStr, label);
+
+ if (!quiet) {
+ toastr.success(t`Secret has been written for the key: ${key}`);
+ }
+
+ return id || '';
+ },
+ }));
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'secret-rename',
+ helpString: t`Renames a secret key by ID.`,
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'quiet',
+ description: t`Suppress toast message notifications.`,
+ isRequired: false,
+ defaultValue: String(false),
+ typeList: [ARGUMENT_TYPE.BOOLEAN],
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'key',
+ description: t`The key to rename the secret in. If not provided, will use the currently active API secrets.`,
+ isRequired: false,
+ typeList: [ARGUMENT_TYPE.STRING],
+ enumProvider: secretKeyEnumProvider,
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'id',
+ description: t`The ID of the secret to rename. If not provided, will rename the active secret.`,
+ isRequired: true,
+ typeList: [ARGUMENT_TYPE.STRING],
+ }),
+ ],
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: t`The new label for the secret key.`,
+ isRequired: true,
+ typeList: [ARGUMENT_TYPE.STRING],
+ }),
+ ],
+ callback: async (args, value) => {
+ const quiet = isTrueBoolean(args?.quiet?.toString());
+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
+ const id = args?.id?.toString()?.trim();
+
+ if (!key) {
+ if (!quiet) {
+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
+ }
+ return '';
+ }
+
+ const secrets = secret_state[key];
+ if (!Array.isArray(secrets) || secrets.length === 0) {
+ if (!quiet) {
+ toastr.error(t`No saved secrets found for the key: ${key}`);
+ }
+ return '';
+ }
+
+ const newLabel = value?.toString()?.trim();
+ if (!newLabel) {
+ if (!quiet) {
+ toastr.error(t`No new label provided for the secret key: ${key}`);
+ }
+ return '';
+ }
+
+ const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
+ if (!savedSecret) {
+ if (!quiet) {
+ toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
+ }
+ return '';
+ }
+
+ // Rename the secret
+ await renameSecret(key, savedSecret.id, newLabel);
+ if (!quiet) {
+ toastr.success(t`Secret with ID: ${id} has been renamed to "${newLabel}" for the key: ${key}`);
+ }
+
+ return savedSecret.id;
+ },
+ }));
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'secret-read',
+ aliases: ['secret-find', 'secret-get'],
+ helpString: t`Reads a secret key by ID. If key exposure is disabled, this command will not work!`,
+ returns: t`The value of the secret key.`,
+ namedArgumentList: [
+ SlashCommandNamedArgument.fromProps({
+ name: 'quiet',
+ description: t`Suppress toast message notifications.`,
+ isRequired: false,
+ defaultValue: String(false),
+ typeList: [ARGUMENT_TYPE.BOOLEAN],
+ }),
+ SlashCommandNamedArgument.fromProps({
+ name: 'key',
+ description: t`The key to read the secret from. If not provided, will use the currently active API secrets.`,
+ isRequired: false,
+ typeList: [ARGUMENT_TYPE.STRING],
+ enumProvider: secretKeyEnumProvider,
+ }),
+ ],
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: t`The ID or a label of the secret key to read. If not provided, will return the currently active secret value.`,
+ isRequired: true,
+ typeList: [ARGUMENT_TYPE.STRING],
+ enumProvider: secretIdEnumProvider,
+ }),
+ ],
+ callback: async (args, value) => {
+ const quiet = isTrueBoolean(args?.quiet?.toString());
+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
+ const id = value?.toString()?.trim();
+
+ if (!key) {
+ if (!quiet) {
+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
+ }
+ return '';
+ }
+
+ const secrets = secret_state[key];
+ if (!Array.isArray(secrets) || secrets.length === 0) {
+ if (!quiet) {
+ toastr.error(t`No saved secrets found for the key: ${key}`);
+ }
+ return '';
+ }
+
+ const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
+ if (!savedSecret) {
+ if (!quiet) {
+ toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
+ }
+ return '';
+ }
+
+ const secretValue = await findSecret(key, savedSecret.id);
+ if (secretValue === null) {
+ if (!quiet) {
+ toastr.error(t`Could not retrieve the secret value for key: ${key}. Key exposure might be disabled.`);
+ }
+ return '';
+ }
+
+ return secretValue;
+ },
+ }));
+}
+
+export async function initSecrets() {
$('#viewSecrets').on('click', viewSecrets);
- $(document).on('click', '.clear-api-key', clearSecret);
+ $(document).on('click', '.manage-api-keys', async function () {
+ const key = $(this).data('key');
+ if (!key || !Object.values(SECRET_KEYS).includes(key)) {
+ console.error('Invalid key for manage-api-keys:', key);
+ return;
+ }
+ await openKeyManagerDialog(key);
+ });
$(document).on('input', Object.values(INPUT_MAP).join(','), function () {
const id = $(this).attr('id');
const value = $(this).val();
+
+ // Find the key based on the entered value
+ for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
+ if (!value || !this.matches(inputSelector)) {
+ continue;
+ }
+ const secrets = secret_state[key];
+ if (!Array.isArray(secrets)) {
+ continue;
+ }
+ const secretMatch = secrets.find(secret => secret.id === value);
+ if (secretMatch) {
+ $(this).val('');
+ return rotateSecret(key, secretMatch.id);
+ }
+ }
+
const warningElement = $(`[data-for="${id}"]`);
warningElement.toggle(value.length > 0);
});
$('.openrouter_authorize').on('click', authorizeOpenRouter);
-});
+ registerSecretSlashCommands();
+}
diff --git a/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js b/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js
index 2e1706901..e8d1dea51 100644
--- a/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js
+++ b/public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js
@@ -40,6 +40,7 @@ export const enumIcons = {
server: '🖥️',
popup: '🗔',
image: '🖼️',
+ key: '🔑',
true: '✔️',
false: '❌',
diff --git a/public/scripts/templates/secretKeyManager.html b/public/scripts/templates/secretKeyManager.html
new file mode 100644
index 000000000..c9ae43c37
--- /dev/null
+++ b/public/scripts/templates/secretKeyManager.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ No secrets saved.
+
+
diff --git a/public/scripts/templates/secretKeyManagerListItem.html b/public/scripts/templates/secretKeyManagerListItem.html
new file mode 100644
index 000000000..52d6d3a5e
--- /dev/null
+++ b/public/scripts/templates/secretKeyManagerListItem.html
@@ -0,0 +1,30 @@
+
diff --git a/public/scripts/utils.js b/public/scripts/utils.js
index 353c5ef80..7bf32a1c1 100644
--- a/public/scripts/utils.js
+++ b/public/scripts/utils.js
@@ -125,6 +125,17 @@ export function isValidUrl(value) {
}
}
+/**
+ * Checks if a string is a valid UUID (version 1-5).
+ * @param {string} value String to check
+ * @returns {boolean} True if the string is a valid UUID, false otherwise.
+ */
+export function isUuid(value) {
+ // Regular expression to match UUIDs
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+ return uuidRegex.test(value);
+}
+
/**
* Converts string to a value of a given type. Includes pythonista-friendly aliases.
* @param {string|SlashCommandClosure} value String value
diff --git a/public/style.css b/public/style.css
index b5aa8b87a..8c8104b0c 100644
--- a/public/style.css
+++ b/public/style.css
@@ -12,6 +12,7 @@
@import url(css/scrollable-button.css);
@import url(css/welcome.css);
@import url(css/data-maid.css);
+@import url(css/secrets.css);
:root {
interpolate-size: allow-keywords;
diff --git a/src/endpoints/secrets.js b/src/endpoints/secrets.js
index d1f015b42..55064b9f2 100644
--- a/src/endpoints/secrets.js
+++ b/src/endpoints/secrets.js
@@ -3,10 +3,11 @@ import path from 'node:path';
import express from 'express';
import { sync as writeFileAtomicSync } from 'write-file-atomic';
-import { getConfigValue } from '../util.js';
+import { color, getConfigValue, uuidv4 } from '../util.js';
export const SECRETS_FILE = 'secrets.json';
export const SECRET_KEYS = {
+ _MIGRATED: '_migrated',
HORDE: 'api_key_horde',
MANCER: 'api_key_mancer',
VLLM: 'api_key_vllm',
@@ -57,6 +58,31 @@ export const SECRET_KEYS = {
VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
};
+/**
+ * @typedef {object} SecretValue
+ * @property {string} id The unique identifier for the secret
+ * @property {string} value The secret value
+ * @property {string} label The label for the secret
+ * @property {boolean} active Whether the secret is currently active
+ */
+
+/**
+ * @typedef {object} SecretState
+ * @property {string} id The unique identifier for the secret
+ * @property {string} value The secret value, masked for security
+ * @property {string} label The label for the secret
+ * @property {boolean} active Whether the secret is currently active
+ */
+
+/**
+ * @typedef {Record} SecretStateMap
+ */
+
+/**
+ * @typedef {{[key: string]: SecretValue[]}} SecretKeys
+ * @typedef {{[key: string]: string}} FlatSecretKeys
+ */
+
// These are the keys that are safe to expose, even if allowKeysExposure is false
const EXPORTABLE_KEYS = [
SECRET_KEYS.LIBRE_URL,
@@ -65,6 +91,319 @@ const EXPORTABLE_KEYS = [
SECRET_KEYS.DEEPLX_URL,
];
+const allowKeysExposure = !!getConfigValue('allowKeysExposure', false, 'boolean');
+
+/**
+ * SecretManager class to handle all secret operations
+ */
+export class SecretManager {
+ /**
+ * @param {import('../users.js').UserDirectoryList} directories
+ */
+ constructor(directories) {
+ this.directories = directories;
+ this.filePath = path.join(directories.root, SECRETS_FILE);
+ this.defaultSecrets = {};
+ }
+
+ /**
+ * Ensures the secrets file exists, creating an empty one if necessary
+ * @private
+ */
+ _ensureSecretsFile() {
+ if (!fs.existsSync(this.filePath)) {
+ writeFileAtomicSync(this.filePath, JSON.stringify(this.defaultSecrets), 'utf-8');
+ }
+ }
+
+ /**
+ * Reads and parses the secrets file
+ * @private
+ * @returns {SecretKeys}
+ */
+ _readSecretsFile() {
+ this._ensureSecretsFile();
+ const fileContents = fs.readFileSync(this.filePath, 'utf-8');
+ return /** @type {SecretKeys} */ (JSON.parse(fileContents));
+ }
+
+ /**
+ * Writes secrets to the file atomically
+ * @private
+ * @param {SecretKeys} secrets
+ */
+ _writeSecretsFile(secrets) {
+ writeFileAtomicSync(this.filePath, JSON.stringify(secrets, null, 4), 'utf-8');
+ }
+
+ /**
+ * Deactivates all secrets for a given key
+ * @private
+ * @param {SecretValue[]} secretArray
+ */
+ _deactivateAllSecrets(secretArray) {
+ secretArray.forEach(secret => {
+ secret.active = false;
+ });
+ }
+
+ /**
+ * Validates that the secret key exists and has valid structure
+ * @private
+ * @param {SecretKeys} secrets
+ * @param {string} key
+ * @returns {boolean}
+ */
+ _validateSecretKey(secrets, key) {
+ return Object.hasOwn(secrets, key) && Array.isArray(secrets[key]);
+ }
+
+ /**
+ * Masks a secret value with asterisks in the middle
+ * @param {string} value The secret value to mask
+ * @returns {string} A masked version of the value for peeking
+ */
+ getMaskedValue(value) {
+ // No masking if exposure is allowed
+ if (allowKeysExposure) {
+ return value;
+ }
+ const threshold = 10;
+ const exposedChars = 3;
+ const placeholder = '*';
+ if (value.length <= threshold) {
+ return placeholder.repeat(threshold);
+ }
+ const visibleEnd = value.slice(-exposedChars);
+ const maskedMiddle = placeholder.repeat(threshold - exposedChars);
+ return `${maskedMiddle}${visibleEnd}`;
+ }
+
+ /**
+ * Writes a secret to the secrets file
+ * @param {string} key Secret key
+ * @param {string} value Secret value
+ * @param {string} label Label for the secret
+ * @returns {string} The ID of the newly created secret
+ */
+ writeSecret(key, value, label = 'Unlabeled') {
+ const secrets = this._readSecretsFile();
+
+ if (!Array.isArray(secrets[key])) {
+ secrets[key] = [];
+ }
+
+ this._deactivateAllSecrets(secrets[key]);
+
+ const secret = {
+ id: uuidv4(),
+ value: value,
+ label: label,
+ active: true,
+ };
+ secrets[key].push(secret);
+
+ this._writeSecretsFile(secrets);
+ return secret.id;
+ }
+
+ /**
+ * Deletes a secret from the secrets file by its ID
+ * @param {string} key Secret key
+ * @param {string?} id Secret ID to delete
+ */
+ deleteSecret(key, id) {
+ if (!fs.existsSync(this.filePath)) {
+ return;
+ }
+
+ const secrets = this._readSecretsFile();
+
+ if (!this._validateSecretKey(secrets, key)) {
+ return;
+ }
+
+ const secretArray = secrets[key];
+ const targetIndex = secretArray.findIndex(s => id ? s.id === id : s.active);
+
+ // Delete the secret if found
+ if (targetIndex !== -1) {
+ secretArray.splice(targetIndex, 1);
+ }
+
+ // Reactivate the first secret if none are active
+ if (secretArray.length && !secretArray.some(s => s.active)) {
+ secretArray[0].active = true;
+ }
+
+ // Remove the key if no secrets left
+ if (secretArray.length === 0) {
+ delete secrets[key];
+ }
+
+ this._writeSecretsFile(secrets);
+ }
+
+ /**
+ * Reads the active secret value for a given key
+ * @param {string} key Secret key
+ * @param {string?} id ID of the secret to read (optional)
+ * @returns {string} Secret value or empty string if not found
+ */
+ readSecret(key, id) {
+ if (!fs.existsSync(this.filePath)) {
+ return '';
+ }
+
+ const secrets = this._readSecretsFile();
+ const secretArray = secrets[key];
+
+ if (Array.isArray(secretArray) && secretArray.length > 0) {
+ const activeSecret = secretArray.find(s => id ? s.id === id : s.active);
+ return activeSecret?.value || '';
+ }
+
+ return '';
+ }
+
+ /**
+ * Activates a specific secret by ID for a given key
+ * @param {string} key Secret key to rotate
+ * @param {string} id ID of the secret to activate
+ */
+ rotateSecret(key, id) {
+ if (!fs.existsSync(this.filePath)) {
+ return;
+ }
+
+ const secrets = this._readSecretsFile();
+
+ if (!this._validateSecretKey(secrets, key)) {
+ return;
+ }
+
+ const secretArray = secrets[key];
+ const targetIndex = secretArray.findIndex(s => s.id === id);
+
+ if (targetIndex === -1) {
+ console.warn(`Secret with ID ${id} not found for key ${key}`);
+ return;
+ }
+
+ this._deactivateAllSecrets(secretArray);
+ secretArray[targetIndex].active = true;
+
+ this._writeSecretsFile(secrets);
+ }
+
+ /**
+ * Renames a secret by its ID
+ * @param {string} key Secret key to rename
+ * @param {string} id ID of the secret to rename
+ * @param {string} label New label for the secret
+ */
+ renameSecret(key, id, label) {
+ const secrets = this._readSecretsFile();
+
+ if (!this._validateSecretKey(secrets, key)) {
+ return;
+ }
+
+ const secretArray = secrets[key];
+ const targetIndex = secretArray.findIndex(s => s.id === id);
+
+ if (targetIndex === -1) {
+ console.warn(`Secret with ID ${id} not found for key ${key}`);
+ return;
+ }
+
+ secretArray[targetIndex].label = label;
+ this._writeSecretsFile(secrets);
+ }
+
+ /**
+ * Gets the state of all secrets (whether they exist or not)
+ * @returns {SecretStateMap} Secret state
+ */
+ getSecretState() {
+ const secrets = this._readSecretsFile();
+ /** @type {SecretStateMap} */
+ const state = {};
+
+ for (const key of Object.values(SECRET_KEYS)) {
+ // Skip migration marker
+ if (key === SECRET_KEYS._MIGRATED) {
+ continue;
+ }
+ const value = secrets[key];
+ if (value && Array.isArray(value) && value.length > 0) {
+ state[key] = value.map(secret => ({
+ id: secret.id,
+ value: this.getMaskedValue(secret.value),
+ label: secret.label,
+ active: secret.active,
+ }));
+ } else {
+ // No secrets for this key
+ state[key] = null;
+ }
+ }
+
+ return state;
+ }
+
+ /**
+ * Gets all secrets (for admin viewing)
+ * @returns {SecretKeys} All secrets
+ */
+ getAllSecrets() {
+ return this._readSecretsFile();
+ }
+
+ /**
+ * Migrates legacy flat secrets format to new format
+ */
+ migrateFlatSecrets() {
+ if (!fs.existsSync(this.filePath)) {
+ return;
+ }
+
+ const fileContents = fs.readFileSync(this.filePath, 'utf8');
+ const secrets = /** @type {FlatSecretKeys} */ (JSON.parse(fileContents));
+ const values = Object.values(secrets);
+
+ // Check if already migrated
+ if (secrets[SECRET_KEYS._MIGRATED] || values.length === 0 || values.some(v => Array.isArray(v))) {
+ return;
+ }
+
+ /** @type {SecretKeys} */
+ const migratedSecrets = {};
+
+ for (const [key, value] of Object.entries(secrets)) {
+ if (typeof value === 'string' && value.trim()) {
+ migratedSecrets[key] = [{
+ id: uuidv4(),
+ value: value,
+ label: key,
+ active: true,
+ }];
+ }
+ }
+
+ // Mark as migrated
+ migratedSecrets[SECRET_KEYS._MIGRATED] = [];
+
+ // Save backup of the old secrets file
+ const backupFilePath = path.join(this.directories.backups, `secrets_migration_${Date.now()}.json`);
+ fs.cpSync(this.filePath, backupFilePath);
+
+ this._writeSecretsFile(migratedSecrets);
+ console.info(color.green('Secrets migrated successfully, old secrets backed up to:'), backupFilePath);
+ }
+}
+
+//#region Backwards compatibility
/**
* Writes a secret to the secrets file
* @param {import('../users.js').UserDirectoryList} directories User directories
@@ -72,36 +411,16 @@ const EXPORTABLE_KEYS = [
* @param {string} value Secret value
*/
export function writeSecret(directories, key, value) {
- const filePath = path.join(directories.root, SECRETS_FILE);
-
- if (!fs.existsSync(filePath)) {
- const emptyFile = JSON.stringify({});
- writeFileAtomicSync(filePath, emptyFile, 'utf-8');
- }
-
- const fileContents = fs.readFileSync(filePath, 'utf-8');
- const secrets = JSON.parse(fileContents);
- secrets[key] = value;
- writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
+ return new SecretManager(directories).writeSecret(key, value);
}
/**
* Deletes a secret from the secrets file
* @param {import('../users.js').UserDirectoryList} directories User directories
* @param {string} key Secret key
- * @returns
*/
export function deleteSecret(directories, key) {
- const filePath = path.join(directories.root, SECRETS_FILE);
-
- if (!fs.existsSync(filePath)) {
- return;
- }
-
- const fileContents = fs.readFileSync(filePath, 'utf-8');
- const secrets = JSON.parse(fileContents);
- delete secrets[key];
- writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
+ return new SecretManager(directories).deleteSecret(key, null);
}
/**
@@ -111,87 +430,104 @@ export function deleteSecret(directories, key) {
* @returns {string} Secret value
*/
export function readSecret(directories, key) {
- const filePath = path.join(directories.root, SECRETS_FILE);
-
- if (!fs.existsSync(filePath)) {
- return '';
- }
-
- const fileContents = fs.readFileSync(filePath, 'utf-8');
- const secrets = JSON.parse(fileContents);
- return secrets[key];
+ return new SecretManager(directories).readSecret(key, null);
}
/**
* Reads the secret state from the secrets file
* @param {import('../users.js').UserDirectoryList} directories User directories
- * @returns {object} Secret state
+ * @returns {Record} Secret state
*/
export function readSecretState(directories) {
- const filePath = path.join(directories.root, SECRETS_FILE);
-
- if (!fs.existsSync(filePath)) {
- return {};
- }
-
- const fileContents = fs.readFileSync(filePath, 'utf8');
- const secrets = JSON.parse(fileContents);
- const state = {};
-
+ const state = new SecretManager(directories).getSecretState();
+ const result = /** @type {Record} */ ({});
for (const key of Object.values(SECRET_KEYS)) {
- state[key] = !!secrets[key]; // convert to boolean
+ // Skip migration marker
+ if (key === SECRET_KEYS._MIGRATED) {
+ continue;
+ }
+ result[key] = Array.isArray(state[key]) && state[key].length > 0;
}
-
- return state;
+ return result;
}
/**
* Reads all secrets from the secrets file
* @param {import('../users.js').UserDirectoryList} directories User directories
- * @returns {Record | undefined} Secrets
+ * @returns {Record} Secrets
*/
export function getAllSecrets(directories) {
- const filePath = path.join(directories.root, SECRETS_FILE);
-
- if (!fs.existsSync(filePath)) {
- console.error('Secrets file does not exist');
- return undefined;
+ const secrets = new SecretManager(directories).getAllSecrets();
+ const result = /** @type {Record} */ ({});
+ for (const [key, values] of Object.entries(secrets)) {
+ // Skip migration marker
+ if (key === SECRET_KEYS._MIGRATED) {
+ continue;
+ }
+ if (Array.isArray(values) && values.length > 0) {
+ const activeSecret = values.find(secret => secret.active);
+ if (activeSecret) {
+ result[key] = activeSecret.value;
+ }
+ }
}
+ return result;
+}
+//#endregion
- const fileContents = fs.readFileSync(filePath, 'utf8');
- const secrets = JSON.parse(fileContents);
- return secrets;
+/**
+ * Migrates legacy flat secrets format to the new format for all user directories
+ * @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
+ */
+export function migrateFlatSecrets(directoriesList) {
+ for (const directories of directoriesList) {
+ try {
+ const manager = new SecretManager(directories);
+ manager.migrateFlatSecrets();
+ } catch (error) {
+ console.warn(color.red(`Failed to migrate secrets for ${directories.root}:`), error);
+ }
+ }
}
export const router = express.Router();
router.post('/write', (request, response) => {
- const key = request.body.key;
- const value = request.body.value;
+ try {
+ const { key, value, label } = request.body;
- writeSecret(request.user.directories, key, value);
- return response.send('ok');
+ if (!key || typeof value !== 'string') {
+ return response.status(400).send('Invalid key or value');
+ }
+
+ const manager = new SecretManager(request.user.directories);
+ const id = manager.writeSecret(key, value, label);
+
+ return response.send({ id });
+ } catch (error) {
+ console.error('Error writing secret:', error);
+ return response.sendStatus(500);
+ }
});
router.post('/read', (request, response) => {
try {
- const state = readSecretState(request.user.directories);
+ const manager = new SecretManager(request.user.directories);
+ const state = manager.getSecretState();
return response.send(state);
} catch (error) {
- console.error(error);
+ console.error('Error reading secret state:', error);
return response.send({});
}
});
-router.post('/view', async (request, response) => {
- const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');
-
- if (!allowKeysExposure) {
- console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');
- return response.sendStatus(403);
- }
-
+router.post('/view', (request, response) => {
try {
+ if (!allowKeysExposure) {
+ console.error('secrets.json could not be viewed unless allowKeysExposure in config.yaml is set to true');
+ return response.sendStatus(403);
+ }
+
const secrets = getAllSecrets(request.user.directories);
if (!secrets) {
@@ -200,30 +536,88 @@ router.post('/view', async (request, response) => {
return response.send(secrets);
} catch (error) {
- console.error(error);
+ console.error('Error viewing secrets:', error);
return response.sendStatus(500);
}
});
router.post('/find', (request, response) => {
- const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');
- const key = request.body.key;
-
- if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
- console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
- return response.sendStatus(403);
- }
-
try {
- const secret = readSecret(request.user.directories, key);
+ const { key, id } = request.body;
- if (!secret) {
+ if (!key) {
+ return response.status(400).send('Key is required');
+ }
+
+ if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
+ console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
+ return response.sendStatus(403);
+ }
+
+ const manager = new SecretManager(request.user.directories);
+ const secretValue = manager.readSecret(key, id);
+
+ if (!secretValue) {
return response.sendStatus(404);
}
- return response.send({ value: secret });
+ return response.send({ value: secretValue });
} catch (error) {
- console.error(error);
+ console.error('Error finding secret:', error);
+ return response.sendStatus(500);
+ }
+});
+
+router.post('/delete', (request, response) => {
+ try {
+ const { key, id } = request.body;
+
+ if (!key) {
+ return response.status(400).send('Key and ID are required');
+ }
+
+ const manager = new SecretManager(request.user.directories);
+ manager.deleteSecret(key, id);
+
+ return response.sendStatus(204);
+ } catch (error) {
+ console.error('Error deleting secret:', error);
+ return response.sendStatus(500);
+ }
+});
+
+router.post('/rotate', (request, response) => {
+ try {
+ const { key, id } = request.body;
+
+ if (!key || !id) {
+ return response.status(400).send('Key and ID are required');
+ }
+
+ const manager = new SecretManager(request.user.directories);
+ manager.rotateSecret(key, id);
+
+ return response.sendStatus(204);
+ } catch (error) {
+ console.error('Error rotating secret:', error);
+ return response.sendStatus(500);
+ }
+});
+
+router.post('/rename', (request, response) => {
+ try {
+ const { key, id, label } = request.body;
+
+ if (!key || !id || !label) {
+ return response.status(400).send('Key, ID, and label are required');
+ }
+
+ const manager = new SecretManager(request.user.directories);
+ manager.renameSecret(key, id, label);
+
+ return response.sendStatus(204);
+ } catch (error) {
+ console.error('Error renaming secret:', error);
return response.sendStatus(500);
}
});
diff --git a/src/server-main.js b/src/server-main.js
index 90168f10d..28b9afc10 100644
--- a/src/server-main.js
+++ b/src/server-main.js
@@ -76,6 +76,7 @@ import { checkForNewContent } from './endpoints/content-manager.js';
import { init as settingsInit } from './endpoints/settings.js';
import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
import { diskCache } from './endpoints/characters.js';
+import { migrateFlatSecrets } from './endpoints/secrets.js';
// Unrestrict console logs display limit
util.inspect.defaultOptions.maxArrayLength = null;
@@ -275,6 +276,7 @@ async function preSetupTasks() {
await checkForNewContent(directories);
await ensureThumbnailCache(directories);
await diskCache.verify(directories);
+ migrateFlatSecrets(directories);
cleanUploads();
migrateAccessLog();
diff --git a/src/util.js b/src/util.js
index 9d420a1f0..56db375d2 100644
--- a/src/util.js
+++ b/src/util.js
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
import { Buffer } from 'node:buffer';
import { promises as dnsPromise } from 'node:dns';
import os from 'node:os';
+import crypto from 'node:crypto';
import yaml from 'yaml';
import { sync as commandExistsSync } from 'command-exists';
@@ -371,9 +372,15 @@ export const color = chalk;
* @returns {string} A UUIDv4 string
*/
export function uuidv4() {
+ // Node v16.7.0+
if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) {
return globalThis.crypto.randomUUID();
}
+ // Node v14.17.0+
+ if ('randomUUID' in crypto) {
+ return crypto.randomUUID();
+ }
+ // Very insecure UUID generator, but it's better than nothing.
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);