diff --git a/public/css/secrets.css b/public/css/secrets.css new file mode 100644 index 000000000..c49d667ea --- /dev/null +++ b/public/css/secrets.css @@ -0,0 +1,102 @@ +.secretKeyManager { + display: flex; + flex-direction: column; + gap: 5px; + height: 100%; +} + +.secretKeyManagerHeader { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 5px; +} + +.secretKeyManagerSubtitle { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.secretKeyManagerInfo { + display: flex; + flex-direction: column; + align-items: baseline; + flex: 1; + font-size: 0.95em; +} + +.secretKeyManagerList { + display: flex; + flex-direction: column; + text-align: left; + gap: 5px; + flex: 1; + overflow-y: auto; + height: 100%; +} + +.secretKeyManagerItem { + border: 1px solid var(--SmartThemeBorderColor); + padding: 5px 10px; + border-radius: 10px; + background-color: var(--black30a); + display: flex; + flex-direction: row; + align-items: center; +} + +.secretKeyManagerItem.active { + background-color: var(--cobalt30a); +} + +.secretKeyManagerItemInfo { + display: flex; + flex-direction: column; + flex: 1; + gap: 5px; +} + +.secretKeyManagerItemSubtitle, +.secretKeyManagerItemHeader { + word-break: break-all; +} + +.secretKeyManagerItemId { + text-decoration: underline; + text-decoration-style: dotted; + cursor: pointer; +} + +.secretKeyManagerItemActions { + display: flex; + flex-direction: column; + gap: 5px; +} + +.secretKeyManagerItemActionsRow { + display: flex; + flex-direction: row; + gap: 5px; +} + +.secretKeyManagerItemActionsRow>button { + margin: 0; +} + +.secretKeyManagerList:empty { + display: none; +} + +.secretKeyManagerListEmpty { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + opacity: 0.8; + font-weight: bold; + font-size: 1.05em; +} diff --git a/public/index.html b/public/index.html index adb0d8adb..038e59407 100644 --- a/public/index.html +++ b/public/index.html @@ -2242,10 +2242,11 @@
- + +
-
- 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'.

Models @@ -2297,11 +2298,11 @@

- -
- 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'.

Mancer Model

@@ -2467,11 +2468,11 @@

API key (optional)

- -
- 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'.

Server URL

@@ -2496,11 +2497,11 @@

API key (optional)

- -
- 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'.

Server URL

@@ -2518,11 +2519,11 @@

API key

- -
- 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'.

Featherless Model Selection

@@ -2569,11 +2570,11 @@

vLLM API key

- -
- 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'.

API URL

@@ -2593,11 +2594,11 @@

HuggingFace Token

- -
- 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'.

Endpoint URL

@@ -2615,11 +2616,11 @@

Aphrodite API key

- -
- 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'.

API URL

@@ -2644,11 +2645,11 @@

API key (optional)

- -
- 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'.

API URL

@@ -2691,11 +2692,11 @@

Tabby API key

- -
- 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'.

API URL

@@ -2743,11 +2744,11 @@

koboldcpp API key (optional)

- -
- 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'.

API URL

@@ -2905,15 +2906,15 @@
- +
Use "Proxy password" field instead. This input will be ignored.
-
- 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'.

OpenAI Model

@@ -2999,10 +3000,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'.

Claude Model

@@ -3058,10 +3059,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'.

OpenRouter Model

@@ -3122,10 +3123,10 @@

Scale API Key

- +
-
- 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'.

Scale API URL

@@ -3134,10 +3135,10 @@

Scale Cookie (_jwt)

- +
- For privacy reasons, your cookie will be hidden after you reload the page. + For privacy reasons, your cookie will be hidden after you click 'Connect'.
@@ -3151,10 +3152,10 @@

AI21 API Key

- +
-
- 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'.

AI21 Model

@@ -3181,10 +3182,10 @@

Google AI Studio API Key

- +
-
- 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'.

Google Model

@@ -3264,10 +3265,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'.
@@ -3301,23 +3302,15 @@ - -
- For privacy reasons, your Service Account JSON content will be hidden after you reload the page. +
+ + +
+
+ For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.
-
@@ -3359,10 +3352,10 @@

MistralAI API Key

- +
-
- 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'.

MistralAI Model

@@ -3423,10 +3416,10 @@

Groq API Key

- +
-
- 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'.

Groq Model

- +
-
- 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'.

NanoGPT Model

@@ -3481,10 +3474,10 @@

DeepSeek API Key

- +
-
- 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'.

DeepSeek Model

@@ -3499,10 +3492,10 @@

Perplexity API Key

- +
-
- 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'.

Perplexity Model

- +
-
- 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

- +
-
- 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'.

xAI Model

Auto-connect to Last Server - View hidden API keys + + View hidden API keys +
@@ -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 @@ +
+
+
+
+
+
API:
+ {{name}} +
+
+
Key:
+ {{key}} +
+
+
+ +
+
+
+
+
+
+ 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 @@ +
+
+
+ {{label}} + {{value}} +
+
+ ID: + {{id}} +
+
+
+
+ + +
+
+ + +
+
+
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);