Merge pull request #3145 from SillyTavern/redesign-extension-manager Redesign extension manager

00006aa072b9cf3bd53e5253419c876a7274102d

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
10 files changed, +689 -256Ignore whitespace
.dockerignore+1 -0
@@ -12,3 +12,4 @@ access.log
12/data12/data
13/cache13/cache
14.DS_Store14.DS_Store
15/public/scripts/extensions/third-party
.gitignore+2 -0
@@ -50,3 +50,5 @@ public/css/user.css
50/default/scaffold50/default/scaffold
51public/scripts/extensions/third-party51public/scripts/extensions/third-party
52/certs52/certs
53.aider*
54.env
.npmignore+1 -0
@@ -11,3 +11,4 @@ access.log
11.github11.github
12.vscode12.vscode
13.git13.git
14/public/scripts/extensions/third-party
public/css/extensions-panel.css+47 -6
@@ -65,7 +65,7 @@ label[for="extensions_autoconnect"] {
65}65}
6666
67.extensions_info .extension_enabled {67.extensions_info .extension_enabled {
68 color: green;68 font-weight: bold;
69}69}
7070
71.extensions_info .extension_disabled {71.extensions_info .extension_disabled {
@@ -76,13 +76,44 @@ label[for="extensions_autoconnect"] {
76 color: gray;76 color: gray;
77}77}
7878
79input.extension_missing[type="checkbox"] {79.extensions_info .extension_modules {
80 opacity: 0.5;80 font-size: 0.8em;
81 font-weight: normal;
81}82}
8283
83#extensions_list .disabled {84.extensions_info .extension_block {
84 text-decoration: line-through;85 display: flex;
85 color: lightgray;86 flex-wrap: nowrap;
87 padding: 5px;
88 margin-bottom: 5px;
89 border: 1px solid var(--SmartThemeBorderColor);
90 border-radius: 10px;
91 align-items: baseline;
92 justify-content: space-between;
93 gap: 5px;
94}
95
96.extensions_info .extension_name {
97 font-size: 1.05em;
98}
99
100.extensions_info .extension_version {
101 opacity: 0.8;
102 font-size: 0.8em;
103 font-weight: normal;
104 margin-left: 2px;
105}
106
107.extensions_info .extension_block a {
108 color: var(--SmartThemeBodyColor);
109}
110
111.extensions_info .extension_name.update_available {
112 color: limegreen;
113}
114
115input.extension_missing[type="checkbox"] {
116 opacity: 0.5;
86}117}
87118
88.update-button {119.update-button {
@@ -105,3 +136,13 @@ input.extension_missing[type="checkbox"] {
105#extensionsMenu>div.extension_container:empty {136#extensionsMenu>div.extension_container:empty {
106 display: none;137 display: none;
107}138}
139
140.extensions_info .extension_text_block {
141 white-space: nowrap;
142 overflow: hidden;
143 text-overflow: ellipsis;
144}
145
146.extensions_info .extension_actions {
147 flex-wrap: nowrap;
148}
public/scripts/extensions.js+490 -216
@@ -4,29 +4,57 @@ import { eventSource, event_types, saveSettings, saveSettingsDebounced, getReque
4import { showLoader } from './loader.js';4import { showLoader } from './loader.js';
5import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';5import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
6import { renderTemplate, renderTemplateAsync } from './templates.js';6import { renderTemplate, renderTemplateAsync } from './templates.js';
7import { isSubsetOf, setValueByPath } from './utils.js';7import { delay, isSubsetOf, setValueByPath } from './utils.js';
8import { getContext } from './st-context.js';8import { getContext } from './st-context.js';
9import { isAdmin } from './user.js';
10import { t } from './i18n.js';
11import { debounce_timeout } from './constants.js';
12
9export {13export {
10 getContext,14 getContext,
11 getApiUrl,15 getApiUrl,
12 loadExtensionSettings,
13 runGenerationInterceptors,
14 doExtrasFetch,
15 modules,
16 extension_settings,
17 ModuleWorkerWrapper,
18};16};
1917
20/** @type {string[]} */18/** @type {string[]} */
21export let extensionNames = [];19export let extensionNames = [];
2220
21/**
22 * Holds the type of each extension.
23 * Don't use this directly, use getExtensionType instead!
24 * @type {Record<string, string>}
25 */
26export let extensionTypes = {};
27
28/**
29 * A list of active modules provided by the Extras API.
30 * @type {string[]}
31 */
32export let modules = [];
33
34/**
35 * A set of active extensions.
36 * @type {Set<string>}
37 */
38let activeExtensions = new Set();
39
40const getApiUrl = () => extension_settings.apiUrl;
41const sortManifests = (a, b) => a.loading_order - b.loading_order;
42let connectedToApi = false;
43
44/**
45 * Holds manifest data for each extension.
46 * @type {Record<string, object>}
47 */
23let manifests = {};48let manifests = {};
24const defaultUrl = 'http://localhost:5100';
2549
26let saveMetadataTimeout = null;50/**
51 * Default URL for the Extras API.
52 */
53const defaultUrl = 'http://localhost:5100';
2754
28let requiresReload = false;55let requiresReload = false;
29let stateChanged = false;56let stateChanged = false;
57let saveMetadataTimeout = null;
3058
31export function saveMetadataDebounced() {59export function saveMetadataDebounced() {
32 const context = getContext();60 const context = getContext();
@@ -51,9 +79,9 @@ export function saveMetadataDebounced() {
51 }79 }
5280
53 console.debug('Saving metadata...');81 console.debug('Saving metadata...');
54 newContext.saveMetadata();82 await newContext.saveMetadata();
55 console.debug('Saved metadata...');83 console.debug('Saved metadata...');
56 }, 1000);84 }, debounce_timeout.relaxed);
57}85}
5886
59/**87/**
@@ -83,7 +111,7 @@ export function renderExtensionTemplateAsync(extensionName, templateId, template
83}111}
84112
85// Disables parallel updates113// Disables parallel updates
86class ModuleWorkerWrapper {114export class ModuleWorkerWrapper {
87 constructor(callback) {115 constructor(callback) {
88 this.isBusy = false;116 this.isBusy = false;
89 this.callback = callback;117 this.callback = callback;
@@ -107,7 +135,7 @@ class ModuleWorkerWrapper {
107 }135 }
108}136}
109137
110const extension_settings = {138export const extension_settings = {
111 apiUrl: defaultUrl,139 apiUrl: defaultUrl,
112 apiKey: '',140 apiKey: '',
113 autoConnect: false,141 autoConnect: false,
@@ -172,12 +200,6 @@ const extension_settings = {
172 disabled_attachments: [],200 disabled_attachments: [],
173};201};
174202
175let modules = [];
176let activeExtensions = new Set();
177
178const getApiUrl = () => extension_settings.apiUrl;
179let connectedToApi = false;
180
181function showHideExtensionsMenu() {203function showHideExtensionsMenu() {
182 // Get the number of menu items that are not hidden204 // Get the number of menu items that are not hidden
183 const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0;205 const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0;
@@ -194,7 +216,23 @@ function showHideExtensionsMenu() {
194// Periodically check for new extensions216// Periodically check for new extensions
195const menuInterval = setInterval(showHideExtensionsMenu, 1000);217const menuInterval = setInterval(showHideExtensionsMenu, 1000);
196218
197async function doExtrasFetch(endpoint, args) {219/**
220 * Gets the type of an extension based on its external ID.
221 * @param {string} externalId External ID of the extension (excluding or including the leading 'third-party/')
222 * @returns {string} Type of the extension (global, local, system, or empty string if not found)
223 */
224function getExtensionType(externalId) {
225 const id = Object.keys(extensionTypes).find(id => id === externalId || (id.startsWith('third-party') && id.endsWith(externalId)));
226 return id ? extensionTypes[id] : '';
227}
228
229/**
230 * Performs a fetch of the Extras API.
231 * @param {string|URL} endpoint Extras API endpoint
232 * @param {RequestInit} args Request arguments
233 * @returns {Promise<Response>} Response from the fetch
234 */
235export async function doExtrasFetch(endpoint, args = {}) {
198 if (!args) {236 if (!args) {
199 args = {};237 args = {};
200 }238 }
@@ -213,10 +251,13 @@ async function doExtrasFetch(endpoint, args) {
213 });251 });
214 }252 }
215253
216 const response = await fetch(endpoint, args);254 return await fetch(endpoint, args);
217 return response;
218}255}
219256
257/**
258 * Discovers extensions from the API.
259 * @returns {Promise<{name: string, type: string}[]>}
260 */
220async function discoverExtensions() {261async function discoverExtensions() {
221 try {262 try {
222 const response = await fetch('/api/extensions/discover');263 const response = await fetch('/api/extensions/discover');
@@ -245,6 +286,11 @@ function onEnableExtensionClick() {
245 enableExtension(name, false);286 enableExtension(name, false);
246}287}
247288
289/**
290 * Enables an extension by name.
291 * @param {string} name Extension name
292 * @param {boolean} [reload=true] If true, reload the page after enabling the extension
293 */
248export async function enableExtension(name, reload = true) {294export async function enableExtension(name, reload = true) {
249 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);295 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);
250 stateChanged = true;296 stateChanged = true;
@@ -256,6 +302,11 @@ export async function enableExtension(name, reload = true) {
256 }302 }
257}303}
258304
305/**
306 * Disables an extension by name.
307 * @param {string} name Extension name
308 * @param {boolean} [reload=true] If true, reload the page after disabling the extension
309 */
259export async function disableExtension(name, reload = true) {310export async function disableExtension(name, reload = true) {
260 extension_settings.disabledExtensions.push(name);311 extension_settings.disabledExtensions.push(name);
261 stateChanged = true;312 stateChanged = true;
@@ -267,6 +318,11 @@ export async function disableExtension(name, reload = true) {
267 }318 }
268}319}
269320
321/**
322 * Loads manifest.json files for extensions.
323 * @param {string[]} names Array of extension names
324 * @returns {Promise<Record<string, object>>} Object with extension names as keys and their manifests as values
325 */
270async function getManifests(names) {326async function getManifests(names) {
271 const obj = {};327 const obj = {};
272 const promises = [];328 const promises = [];
@@ -294,43 +350,36 @@ async function getManifests(names) {
294 return obj;350 return obj;
295}351}
296352
353/**
354 * Tries to activate all available extensions that are not already active.
355 * @returns {Promise<void>}
356 */
297async function activateExtensions() {357async function activateExtensions() {
298 const extensions = Object.entries(manifests).sort((a, b) => a[1].loading_order - b[1].loading_order);358 const extensions = Object.entries(manifests).sort((a, b) => sortManifests(a[1], b[1]));
299 const promises = [];359 const promises = [];
300360
301 for (let entry of extensions) {361 for (let entry of extensions) {
302 const name = entry[0];362 const name = entry[0];
303 const manifest = entry[1];363 const manifest = entry[1];
304 const elementExists = document.getElementById(name) !== null;
305364
306 if (elementExists || activeExtensions.has(name)) {365 if (activeExtensions.has(name)) {
307 continue;366 continue;
308 }367 }
309368
310 // all required modules are active (offline extensions require none)369 const meetsModuleRequirements = !Array.isArray(manifest.requires) || isSubsetOf(modules, manifest.requires);
311 if (isSubsetOf(modules, manifest.requires)) {370 const isDisabled = extension_settings.disabledExtensions.includes(name);
312 try {
313 const isDisabled = extension_settings.disabledExtensions.includes(name);
314 const li = document.createElement('li');
315
316 if (!isDisabled) {
317 const promise = Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]);
318 await promise
319 .then(() => activeExtensions.add(name))
320 .catch(err => console.log('Could not activate extension: ' + name, err));
321 promises.push(promise);
322 }
323 else {
324 li.classList.add('disabled');
325 }
326
327 li.id = name;
328 li.innerText = manifest.display_name;
329371
330 $('#extensions_list').append(li);372 if (meetsModuleRequirements && !isDisabled) {
373 try {
374 console.debug('Activating extension', name);
375 const promise = Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]);
376 await promise
377 .then(() => activeExtensions.add(name))
378 .catch(err => console.log('Could not activate extension', name, err));
379 promises.push(promise);
331 }380 }
332 catch (error) {381 catch (error) {
333 console.error(`Could not activate extension: ${name}`);382 console.error('Could not activate extension', name);
334 console.error(error);383 console.error(error);
335 }384 }
336 }385 }
@@ -340,8 +389,8 @@ async function activateExtensions() {
340}389}
341390
342async function connectClickHandler() {391async function connectClickHandler() {
343 const baseUrl = $('#extensions_url').val();392 const baseUrl = String($('#extensions_url').val());
344 extension_settings.apiUrl = String(baseUrl);393 extension_settings.apiUrl = baseUrl;
345 const testApiKey = $('#extensions_api_key').val();394 const testApiKey = $('#extensions_api_key').val();
346 extension_settings.apiKey = String(testApiKey);395 extension_settings.apiKey = String(testApiKey);
347 saveSettingsDebounced();396 saveSettingsDebounced();
@@ -401,21 +450,11 @@ function notifyUpdatesInputHandler() {
401 }450 }
402}451}
403452
404/* $(document).on('click', function (e) {453/**
405 const target = $(e.target);454 * Connects to the Extras API.
406 if (target.is(dropdown)) return;455 * @param {string} baseUrl Extras API base URL
407 if (target.is(button) && dropdown.is(':hidden')) {456 * @returns {Promise<void>}
408 dropdown.toggle(200);457 */
409 popper.update();
410 }
411 if (target !== dropdown &&
412 target !== button &&
413 dropdown.is(":visible")) {
414 dropdown.hide(200);
415 }
416 });
417} */
418
419async function connectToApi(baseUrl) {458async function connectToApi(baseUrl) {
420 if (!baseUrl) {459 if (!baseUrl) {
421 return;460 return;
@@ -431,7 +470,7 @@ async function connectToApi(baseUrl) {
431 const data = await getExtensionsResult.json();470 const data = await getExtensionsResult.json();
432 modules = data.modules;471 modules = data.modules;
433 await activateExtensions();472 await activateExtensions();
434 eventSource.emit(event_types.EXTRAS_CONNECTED, modules);473 await eventSource.emit(event_types.EXTRAS_CONNECTED, modules);
435 }474 }
436475
437 updateStatus(getExtensionsResult.ok);476 updateStatus(getExtensionsResult.ok);
@@ -441,71 +480,84 @@ async function connectToApi(baseUrl) {
441 }480 }
442}481}
443482
483/**
484 * Updates the status of Extras API connection.
485 * @param {boolean} success Whether the connection was successful
486 */
444function updateStatus(success) {487function updateStatus(success) {
445 connectedToApi = success;488 connectedToApi = success;
446 const _text = success ? 'Connected to API' : 'Could not connect to API';489 const _text = success ? t`Connected to API` : t`Could not connect to API`;
447 const _class = success ? 'success' : 'failure';490 const _class = success ? 'success' : 'failure';
448 $('#extensions_status').text(_text);491 $('#extensions_status').text(_text);
449 $('#extensions_status').attr('class', _class);492 $('#extensions_status').attr('class', _class);
450}493}
451494
495/**
496 * Adds a CSS file for an extension.
497 * @param {string} name Extension name
498 * @param {object} manifest Extension manifest
499 * @returns {Promise<void>} When the CSS is loaded
500 */
452function addExtensionStyle(name, manifest) {501function addExtensionStyle(name, manifest) {
453 if (manifest.css) {502 if (!manifest.css) {
454 return new Promise((resolve, reject) => {503 return Promise.resolve();
455 const url = `/scripts/extensions/${name}/${manifest.css}`;
456
457 if ($(`link[id="${name}"]`).length === 0) {
458 const link = document.createElement('link');
459 link.id = name;
460 link.rel = 'stylesheet';
461 link.type = 'text/css';
462 link.href = url;
463 link.onload = function () {
464 resolve();
465 };
466 link.onerror = function (e) {
467 reject(e);
468 };
469 document.head.appendChild(link);
470 }
471 });
472 }504 }
473505
474 return Promise.resolve();506 return new Promise((resolve, reject) => {
507 const url = `/scripts/extensions/${name}/${manifest.css}`;
508
509 if ($(`link[id="${name}"]`).length === 0) {
510 const link = document.createElement('link');
511 link.id = name;
512 link.rel = 'stylesheet';
513 link.type = 'text/css';
514 link.href = url;
515 link.onload = function () {
516 resolve();
517 };
518 link.onerror = function (e) {
519 reject(e);
520 };
521 document.head.appendChild(link);
522 }
523 });
475}524}
476525
526/**
527 * Loads a JS file for an extension.
528 * @param {string} name Extension name
529 * @param {object} manifest Extension manifest
530 * @returns {Promise<void>} When the script is loaded
531 */
477function addExtensionScript(name, manifest) {532function addExtensionScript(name, manifest) {
478 if (manifest.js) {533 if (!manifest.js) {
479 return new Promise((resolve, reject) => {534 return Promise.resolve();
480 const url = `/scripts/extensions/${name}/${manifest.js}`;
481 let ready = false;
482
483 if ($(`script[id="${name}"]`).length === 0) {
484 const script = document.createElement('script');
485 script.id = name;
486 script.type = 'module';
487 script.src = url;
488 script.async = true;
489 script.onerror = function (err) {
490 reject(err, script);
491 };
492 script.onload = script.onreadystatechange = function () {
493 // console.log(this.readyState); // uncomment this line to see which ready states are called.
494 if (!ready && (!this.readyState || this.readyState == 'complete')) {
495 ready = true;
496 resolve();
497 }
498 };
499 document.body.appendChild(script);
500 }
501 });
502 }535 }
503536
504 return Promise.resolve();537 return new Promise((resolve, reject) => {
538 const url = `/scripts/extensions/${name}/${manifest.js}`;
539 let ready = false;
540
541 if ($(`script[id="${name}"]`).length === 0) {
542 const script = document.createElement('script');
543 script.id = name;
544 script.type = 'module';
545 script.src = url;
546 script.async = true;
547 script.onerror = function (err) {
548 reject(err);
549 };
550 script.onload = function () {
551 if (!ready) {
552 ready = true;
553 resolve();
554 }
555 };
556 document.body.appendChild(script);
557 }
558 });
505}559}
506560
507
508
509/**561/**
510 * Generates HTML string for displaying an extension in the UI.562 * Generates HTML string for displaying an extension in the UI.
511 *563 *
@@ -515,64 +567,85 @@ function addExtensionScript(name, manifest) {
515 * @param {boolean} isDisabled - Whether the extension is disabled or not.567 * @param {boolean} isDisabled - Whether the extension is disabled or not.
516 * @param {boolean} isExternal - Whether the extension is external or not.568 * @param {boolean} isExternal - Whether the extension is external or not.
517 * @param {string} checkboxClass - The class for the checkbox HTML element.569 * @param {string} checkboxClass - The class for the checkbox HTML element.
518 * @return {Promise<string>} - The HTML string that represents the extension.570 * @return {string} - The HTML string that represents the extension.
519 */571 */
520async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {572function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {
573 function getExtensionIcon() {
574 const type = getExtensionType(name);
575 switch (type) {
576 case 'global':
577 return '<i class="fa-sm fa-fw fa-solid fa-server" data-i18n="[title]ext_type_global" title="This is a global extension, available for all users."></i>';
578 case 'local':
579 return '<i class="fa-sm fa-fw fa-solid fa-user" data-i18n="[title]ext_type_local" title="This is a local extension, available only for you."></i>';
580 case 'system':
581 return '<i class="fa-sm fa-fw fa-solid fa-cog" data-i18n="[title]ext_type_system" title="This is a built-in extension. It cannot be deleted and updates with the app."></i>';
582 default:
583 return '<i class="fa-sm fa-fw fa-solid fa-question" title="Unknown extension type."></i>';
584 }
585 }
586
587 const isUserAdmin = isAdmin();
588 const extensionIcon = getExtensionIcon();
521 const displayName = manifest.display_name;589 const displayName = manifest.display_name;
522 let displayVersion = manifest.version ? ` v${manifest.version}` : '';590 const displayVersion = manifest.version || '';
523 let isUpToDate = true;591 const externalId = name.replace('third-party', '');
524 let updateButton = '';
525 let originHtml = '';592 let originHtml = '';
526 if (isExternal) {593 if (isExternal) {
527 let data = await getExtensionVersion(name.replace('third-party', ''));594 originHtml = '<a>';
528 let branch = data.currentBranchName;
529 let commitHash = data.currentCommitHash;
530 let origin = data.remoteUrl;
531 isUpToDate = data.isUpToDate;
532 displayVersion = ` (${branch}-${commitHash.substring(0, 7)})`;
533 updateButton = isUpToDate ?
534 `<span class="update-button"><button class="btn_update menu_button" data-name="${name.replace('third-party', '')}" title="Up to date"><i class="fa-solid fa-code-commit fa-fw"></i></button></span>` :
535 `<span class="update-button"><button class="btn_update menu_button" data-name="${name.replace('third-party', '')}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button></span>`;
536 originHtml = `<a href="${origin}" target="_blank" rel="noopener noreferrer">`;
537 }595 }
538596
539 let toggleElement = isActive || isDisabled ?597 let toggleElement = isActive || isDisabled ?
540 `<input type="checkbox" title="Click to toggle" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :598 `<input type="checkbox" title="Click to toggle" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :
541 `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;599 `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;
542600
543 let deleteButton = isExternal ? `<span class="delete-button"><button class="btn_delete menu_button" data-name="${name.replace('third-party', '')}" title="Delete"><i class="fa-solid fa-trash-can"></i></button></span>` : '';601 let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';
544602 let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';
545 // if external, wrap the name in a link to the repo603 let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';
546604 let modulesInfo = '';
547 let extensionHtml = `<hr>
548 <h4>
549 ${updateButton}
550 ${deleteButton}
551 ${originHtml}
552 <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
553 ${DOMPurify.sanitize(displayName)}${displayVersion}
554 </span>
555 ${isExternal ? '</a>' : ''}
556
557 <span style="float:right;">${toggleElement}</span>
558 </h4>`;
559605
560 if (isActive && Array.isArray(manifest.optional)) {606 if (isActive && Array.isArray(manifest.optional)) {
561 const optional = new Set(manifest.optional);607 const optional = new Set(manifest.optional);
562 modules.forEach(x => optional.delete(x));608 modules.forEach(x => optional.delete(x));
563 if (optional.size > 0) {609 if (optional.size > 0) {
564 const optionalString = DOMPurify.sanitize([...optional].join(', '));610 const optionalString = DOMPurify.sanitize([...optional].join(', '));
565 extensionHtml += `<p>Optional modules: <span class="optional">${optionalString}</span></p>`;611 modulesInfo = `<div class="extension_modules">Optional modules: <span class="optional">${optionalString}</span></div>`;
566 }612 }
567 } else if (!isDisabled) { // Neither active nor disabled613 } else if (!isDisabled) { // Neither active nor disabled
568 const requirements = new Set(manifest.requires);614 const requirements = new Set(manifest.requires);
569 modules.forEach(x => requirements.delete(x));615 modules.forEach(x => requirements.delete(x));
570 if (requirements.size > 0) {616 if (requirements.size > 0) {
571 const requirementsString = DOMPurify.sanitize([...requirements].join(', '));617 const requirementsString = DOMPurify.sanitize([...requirements].join(', '));
572 extensionHtml += `<p>Missing modules: <span class="failure">${requirementsString}</span></p>`;618 modulesInfo = `<div class="extension_modules">Missing modules: <span class="failure">${requirementsString}</span></div>`;
573 }619 }
574 }620 }
575621
622 // if external, wrap the name in a link to the repo
623
624 let extensionHtml = `
625 <div class="extension_block" data-name="${externalId}">
626 <div class="extension_toggle">
627 ${toggleElement}
628 </div>
629 <div class="extension_icon">
630 ${extensionIcon}
631 </div>
632 <div class="flexGrow extension_text_block">
633 ${originHtml}
634 <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
635 <span class="extension_name">${DOMPurify.sanitize(displayName)}</span>
636 <span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>
637 ${modulesInfo}
638 </span>
639 ${isExternal ? '</a>' : ''}
640 </div>
641
642 <div class="extension_actions flex-container alignItemsCenter">
643 ${updateButton}
644 ${moveButton}
645 ${deleteButton}
646 </div>
647 </div>`;
648
576 return extensionHtml;649 return extensionHtml;
577}650}
578651
@@ -580,9 +653,9 @@ async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExt
580 * Gets extension data and generates the corresponding HTML for displaying the extension.653 * Gets extension data and generates the corresponding HTML for displaying the extension.
581 *654 *
582 * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest.655 * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest.
583 * @return {Promise<object>} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string.656 * @return {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string.
584 */657 */
585async function getExtensionData(extension) {658function getExtensionData(extension) {
586 const name = extension[0];659 const name = extension[0];
587 const manifest = extension[1];660 const manifest = extension[1];
588 const isActive = activeExtensions.has(name);661 const isActive = activeExtensions.has(name);
@@ -591,7 +664,7 @@ async function getExtensionData(extension) {
591664
592 const checkboxClass = isDisabled ? 'checkbox_disabled' : '';665 const checkboxClass = isDisabled ? 'checkbox_disabled' : '';
593666
594 const extensionHtml = await generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass);667 const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
595668
596 return { isExternal, extensionHtml };669 return { isExternal, extensionHtml };
597}670}
@@ -614,42 +687,38 @@ function getModuleInformation() {
614 * Generates the HTML strings for all extensions and displays them in a popup.687 * Generates the HTML strings for all extensions and displays them in a popup.
615 */688 */
616async function showExtensionsDetails() {689async function showExtensionsDetails() {
690 const abortController = new AbortController();
617 let popupPromise;691 let popupPromise;
618 try {692 try {
619 const htmlDefault = $('<h3>Built-in Extensions:</h3>');693 // If we are updating an extension, the "old" popup is still active. We should close that.
620 const htmlExternal = $('<h3>Installed Extensions:</h3>').addClass('opacity50p');694 let initialScrollTop = 0;
621 const htmlLoading = $(`<h3 class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5">695 const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info'));
696 if (oldPopup) {
697 initialScrollTop = oldPopup.content.scrollTop;
698 await oldPopup.completeCancelled();
699 }
700 const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">Built-in Extensions:</h3></div>');
701 const htmlExternal = $('<div class="marginBot10"><h3 class="textAlignCenter">Installed Extensions:</h3></div>');
702 const htmlLoading = $(`<div class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5">
622 <i class="fa-solid fa-spinner fa-spin"></i>703 <i class="fa-solid fa-spinner fa-spin"></i>
623 <span>Loading third-party extensions... Please wait...</span>704 <span>Loading third-party extensions... Please wait...</span>
624 </h3>`);705 </div>`);
625706
626 /** @type {Promise<any>[]} */707 htmlExternal.append(htmlLoading);
627 const promises = [];
628 const extensions = Object.entries(manifests).sort((a, b) => a[1].loading_order - b[1].loading_order);
629708
630 for (const extension of extensions) {709 const extensions = Object.entries(manifests).sort((a, b) => sortManifests(a[1], b[1])).map(getExtensionData);
631 promises.push(getExtensionData(extension));
632 }
633
634 promises.forEach(promise => {
635 promise.then(value => {
636 const { isExternal, extensionHtml } = value;
637 const container = isExternal ? htmlExternal : htmlDefault;
638 container.append(extensionHtml);
639 });
640 });
641710
642 Promise.allSettled(promises).then(() => {711 extensions.forEach(value => {
643 htmlLoading.remove();712 const { isExternal, extensionHtml } = value;
644 htmlExternal.removeClass('opacity50p');713 const container = isExternal ? htmlExternal : htmlDefault;
714 container.append(extensionHtml);
645 });715 });
646716
647 const html = $('<div></div>')717 const html = $('<div></div>')
648 .addClass('extensions_info')718 .addClass('extensions_info')
649 .append(getModuleInformation())
650 .append(htmlDefault)719 .append(htmlDefault)
651 .append(htmlLoading)720 .append(htmlExternal)
652 .append(htmlExternal);721 .append(getModuleInformation());
653722
654 /** @type {import('./popup.js').CustomPopupButton} */723 /** @type {import('./popup.js').CustomPopupButton} */
655 const updateAllButton = {724 const updateAllButton = {
@@ -662,12 +731,6 @@ async function showExtensionsDetails() {
662 },731 },
663 };732 };
664733
665 // If we are updating an extension, the "old" popup is still active. We should close that.
666 const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info'));
667 if (oldPopup) {
668 await oldPopup.complete(POPUP_RESULT.CANCELLED);
669 }
670
671 let waitingForSave = false;734 let waitingForSave = false;
672735
673 const popup = new Popup(html, POPUP_TYPE.TEXT, '', {736 const popup = new Popup(html, POPUP_TYPE.TEXT, '', {
@@ -682,7 +745,7 @@ async function showExtensionsDetails() {
682 }745 }
683 if (stateChanged) {746 if (stateChanged) {
684 waitingForSave = true;747 waitingForSave = true;
685 const toast = toastr.info('The page will be reloaded shortly...', 'Extensions state changed');748 const toast = toastr.info(t`The page will be reloaded shortly...`, t`Extensions state changed`);
686 await saveSettings();749 await saveSettings();
687 toastr.clear(toast);750 toastr.clear(toast);
688 waitingForSave = false;751 waitingForSave = false;
@@ -692,12 +755,15 @@ async function showExtensionsDetails() {
692 },755 },
693 });756 });
694 popupPromise = popup.show();757 popupPromise = popup.show();
758 popup.content.scrollTop = initialScrollTop;
759 checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove());
695 } catch (error) {760 } catch (error) {
696 toastr.error('Error loading extensions. See browser console for details.');761 toastr.error(t`Error loading extensions. See browser console for details.`);
697 console.error(error);762 console.error(error);
698 }763 }
699 if (popupPromise) {764 if (popupPromise) {
700 await popupPromise;765 await popupPromise;
766 abortController.abort();
701 }767 }
702 if (requiresReload) {768 if (requiresReload) {
703 showLoader();769 showLoader();
@@ -705,17 +771,26 @@ async function showExtensionsDetails() {
705 }771 }
706}772}
707773
708
709/**774/**
710 * Handles the click event for the update button of an extension.775 * Handles the click event for the update button of an extension.
711 * This function makes a POST request to '/update_extension' with the extension's name.776 * This function makes a POST request to '/api/extensions/update' with the extension's name.
712 * If the extension is already up to date, it displays a success message.777 * If the extension is already up to date, it displays a success message.
713 * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash.778 * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash.
714 */779 */
715async function onUpdateClick() {780async function onUpdateClick() {
781 const isCurrentUserAdmin = isAdmin();
716 const extensionName = $(this).data('name');782 const extensionName = $(this).data('name');
717 $(this).find('i').addClass('fa-spin');783 const isGlobal = getExtensionType(extensionName) === 'global';
784 if (isGlobal && !isCurrentUserAdmin) {
785 toastr.error(t`You don't have permission to update global extensions.`);
786 return;
787 }
788
789 const icon = $(this).find('i');
790 icon.addClass('fa-spin');
718 await updateExtension(extensionName, false);791 await updateExtension(extensionName, false);
792 // updateExtension eats the error, but we can at least stop the spinner
793 icon.removeClass('fa-spin');
719}794}
720795
721/**796/**
@@ -728,13 +803,23 @@ async function updateExtension(extensionName, quiet) {
728 const response = await fetch('/api/extensions/update', {803 const response = await fetch('/api/extensions/update', {
729 method: 'POST',804 method: 'POST',
730 headers: getRequestHeaders(),805 headers: getRequestHeaders(),
731 body: JSON.stringify({ extensionName }),806 body: JSON.stringify({
807 extensionName,
808 global: getExtensionType(extensionName) === 'global',
809 }),
732 });810 });
733811
812 if (!response.ok) {
813 const text = await response.text();
814 toastr.error(text || response.statusText, t`Extension update failed`, { timeOut: 5000 });
815 console.error('Extension update failed', response.status, response.statusText, text);
816 return;
817 }
818
734 const data = await response.json();819 const data = await response.json();
735820
736 if (!quiet) {821 if (!quiet) {
737 showExtensionsDetails();822 void showExtensionsDetails();
738 }823 }
739824
740 if (data.isUpToDate) {825 if (data.isUpToDate) {
@@ -757,44 +842,122 @@ async function updateExtension(extensionName, quiet) {
757 */842 */
758async function onDeleteClick() {843async function onDeleteClick() {
759 const extensionName = $(this).data('name');844 const extensionName = $(this).data('name');
845 const isCurrentUserAdmin = isAdmin();
846 const isGlobal = getExtensionType(extensionName) === 'global';
847 if (isGlobal && !isCurrentUserAdmin) {
848 toastr.error(t`You don't have permission to delete global extensions.`);
849 return;
850 }
851
760 // use callPopup to create a popup for the user to confirm before delete852 // use callPopup to create a popup for the user to confirm before delete
761 const confirmation = await callGenericPopup(`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {});853 const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {});
762 if (confirmation === POPUP_RESULT.AFFIRMATIVE) {854 if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
763 await deleteExtension(extensionName);855 await deleteExtension(extensionName);
764 }856 }
765}857}
766858
859async function onMoveClick() {
860 const extensionName = $(this).data('name');
861 const isCurrentUserAdmin = isAdmin();
862 const isGlobal = getExtensionType(extensionName) === 'global';
863 if (isGlobal && !isCurrentUserAdmin) {
864 toastr.error(t`You don't have permission to move extensions.`);
865 return;
866 }
867
868 const source = getExtensionType(extensionName);
869 const destination = source === 'global' ? 'local' : 'global';
870
871 const confirmationHeader = t`Move extension`;
872 const confirmationText = source == 'global'
873 ? t`Are you sure you want to move ${extensionName} to your local extensions? This will make it available only for you.`
874 : t`Are you sure you want to move ${extensionName} to the global extensions? This will make it available for all users.`;
875
876 const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText);
877
878 if (!confirmation) {
879 return;
880 }
881
882 $(this).find('i').addClass('fa-spin');
883 await moveExtension(extensionName, source, destination);
884}
885
886/**
887 * Moves an extension via the API.
888 * @param {string} extensionName Extension name
889 * @param {string} source Source type
890 * @param {string} destination Destination type
891 * @returns {Promise<void>}
892 */
893async function moveExtension(extensionName, source, destination) {
894 try {
895 const result = await fetch('/api/extensions/move', {
896 method: 'POST',
897 headers: getRequestHeaders(),
898 body: JSON.stringify({
899 extensionName,
900 source,
901 destination,
902 }),
903 });
904
905 if (!result.ok) {
906 const text = await result.text();
907 toastr.error(text || result.statusText, t`Extension move failed`, { timeOut: 5000 });
908 console.error('Extension move failed', result.status, result.statusText, text);
909 return;
910 }
911
912 toastr.success(t`Extension ${extensionName} moved.`);
913 await loadExtensionSettings({}, false, false);
914 void showExtensionsDetails();
915 } catch (error) {
916 console.error('Error:', error);
917 }
918}
919
920/**
921 * Deletes an extension via the API.
922 * @param {string} extensionName Extension name to delete
923 */
767export async function deleteExtension(extensionName) {924export async function deleteExtension(extensionName) {
768 try {925 try {
769 await fetch('/api/extensions/delete', {926 await fetch('/api/extensions/delete', {
770 method: 'POST',927 method: 'POST',
771 headers: getRequestHeaders(),928 headers: getRequestHeaders(),
772 body: JSON.stringify({ extensionName }),929 body: JSON.stringify({
930 extensionName,
931 global: getExtensionType(extensionName) === 'global',
932 }),
773 });933 });
774 } catch (error) {934 } catch (error) {
775 console.error('Error:', error);935 console.error('Error:', error);
776 }936 }
777937
778 toastr.success(`Extension ${extensionName} deleted`);938 toastr.success(t`Extension ${extensionName} deleted`);
779 showExtensionsDetails();939 delay(1000).then(() => location.reload());
780 // reload the page to remove the extension from the list
781 location.reload();
782}940}
783941
784/**942/**
785 * Fetches the version details of a specific extension.943 * Fetches the version details of a specific extension.
786 *944 *
787 * @param {string} extensionName - The name of the extension.945 * @param {string} extensionName - The name of the extension.
946 * @param {AbortSignal} [abortSignal] - The signal to abort the operation.
788 * @return {Promise<object>} - An object containing the extension's version details.947 * @return {Promise<object>} - An object containing the extension's version details.
789 * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl.948 * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl.
790 * @throws {error} - If there is an error during the fetch operation, it logs the error to the console.949 * @throws {error} - If there is an error during the fetch operation, it logs the error to the console.
791 */950 */
792async function getExtensionVersion(extensionName) {951async function getExtensionVersion(extensionName, abortSignal) {
793 try {952 try {
794 const response = await fetch('/api/extensions/version', {953 const response = await fetch('/api/extensions/version', {
795 method: 'POST',954 method: 'POST',
796 headers: getRequestHeaders(),955 headers: getRequestHeaders(),
797 body: JSON.stringify({ extensionName }),956 body: JSON.stringify({
957 extensionName,
958 global: getExtensionType(extensionName) === 'global',
959 }),
960 signal: abortSignal,
798 });961 });
799962
800 const data = await response.json();963 const data = await response.json();
@@ -807,22 +970,26 @@ async function getExtensionVersion(extensionName) {
807/**970/**
808 * Installs a third-party extension via the API.971 * Installs a third-party extension via the API.
809 * @param {string} url Extension repository URL972 * @param {string} url Extension repository URL
973 * @param {boolean} global Is the extension global?
810 * @returns {Promise<void>}974 * @returns {Promise<void>}
811 */975 */
812export async function installExtension(url) {976export async function installExtension(url, global) {
813 console.debug('Extension installation started', url);977 console.debug('Extension installation started', url);
814978
815 toastr.info('Please wait...', 'Installing extension');979 toastr.info(t`Please wait...`, t`Installing extension`);
816980
817 const request = await fetch('/api/extensions/install', {981 const request = await fetch('/api/extensions/install', {
818 method: 'POST',982 method: 'POST',
819 headers: getRequestHeaders(),983 headers: getRequestHeaders(),
820 body: JSON.stringify({ url }),984 body: JSON.stringify({
985 url,
986 global,
987 }),
821 });988 });
822989
823 if (!request.ok) {990 if (!request.ok) {
824 const text = await request.text();991 const text = await request.text();
825 toastr.warning(text || request.statusText, 'Extension installation failed', { timeOut: 5000 });992 toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 });
826 console.error('Extension installation failed', request.status, request.statusText, text);993 console.error('Extension installation failed', request.status, request.statusText, text);
827 return;994 return;
828 }995 }
@@ -840,7 +1007,7 @@ export async function installExtension(url) {
840 * @param {boolean} versionChanged Is this a version change?1007 * @param {boolean} versionChanged Is this a version change?
841 * @param {boolean} enableAutoUpdate Enable auto-update1008 * @param {boolean} enableAutoUpdate Enable auto-update
842 */1009 */
843async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) {1010export async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) {
844 if (settings.extension_settings) {1011 if (settings.extension_settings) {
845 Object.assign(extension_settings, settings.extension_settings);1012 Object.assign(extension_settings, settings.extension_settings);
846 }1013 }
@@ -852,7 +1019,9 @@ async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate)
8521019
853 // Activate offline extensions1020 // Activate offline extensions
854 await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD);1021 await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD);
855 extensionNames = await discoverExtensions();1022 const extensions = await discoverExtensions();
1023 extensionNames = extensions.map(x => x.name);
1024 extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type]));
856 manifests = await getManifests(extensionNames);1025 manifests = await getManifests(extensionNames);
8571026
858 if (versionChanged && enableAutoUpdate) {1027 if (versionChanged && enableAutoUpdate) {
@@ -873,6 +1042,86 @@ export function doDailyExtensionUpdatesCheck() {
873 }, 1);1042 }, 1);
874}1043}
8751044
1045const concurrencyLimit = 5;
1046let activeRequestsCount = 0;
1047const versionCheckQueue = [];
1048
1049function enqueueVersionCheck(fn) {
1050 return new Promise((resolve, reject) => {
1051 versionCheckQueue.push(() => fn().then(resolve).catch(reject));
1052 processVersionCheckQueue();
1053 });
1054}
1055
1056function processVersionCheckQueue() {
1057 if (activeRequestsCount >= concurrencyLimit || versionCheckQueue.length === 0) {
1058 return;
1059 }
1060 activeRequestsCount++;
1061 const fn = versionCheckQueue.shift();
1062 fn().finally(() => {
1063 activeRequestsCount--;
1064 processVersionCheckQueue();
1065 });
1066}
1067
1068/**
1069 * Performs a manual check for updates on all 3rd-party extensions.
1070 * @param {AbortSignal} abortSignal Signal to abort the operation
1071 * @returns {Promise<any[]>}
1072 */
1073async function checkForUpdatesManual(abortSignal) {
1074 const promises = [];
1075 for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party')).sort((a, b) => sortManifests(manifests[a], manifests[b]))) {
1076 const externalId = id.replace('third-party', '');
1077 const promise = enqueueVersionCheck(async () => {
1078 try {
1079 const data = await getExtensionVersion(externalId, abortSignal);
1080 const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`);
1081 if (extensionBlock && data) {
1082 if (data.isUpToDate === false) {
1083 const buttonElement = extensionBlock.querySelector('.btn_update');
1084 if (buttonElement) {
1085 buttonElement.classList.remove('displayNone');
1086 }
1087 const nameElement = extensionBlock.querySelector('.extension_name');
1088 if (nameElement) {
1089 nameElement.classList.add('update_available');
1090 }
1091 }
1092 let branch = data.currentBranchName;
1093 let commitHash = data.currentCommitHash;
1094 let origin = data.remoteUrl;
1095
1096 const originLink = extensionBlock.querySelector('a');
1097 if (originLink) {
1098 try {
1099 const url = new URL(origin);
1100 if (!['https:', 'http:'].includes(url.protocol)) {
1101 throw new Error('Invalid protocol');
1102 }
1103 originLink.href = url.href;
1104 originLink.target = '_blank';
1105 originLink.rel = 'noopener noreferrer';
1106 } catch (error) {
1107 console.log('Error setting origin link', originLink, error);
1108 }
1109 }
1110
1111 const versionElement = extensionBlock.querySelector('.extension_version');
1112 if (versionElement) {
1113 versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`;
1114 }
1115 }
1116 } catch (error) {
1117 console.error('Error checking for extension updates', error);
1118 }
1119 });
1120 promises.push(promise);
1121 }
1122 return Promise.allSettled(promises);
1123}
1124
876/**1125/**
877 * Checks if there are updates available for 3rd-party extensions.1126 * Checks if there are updates available for 3rd-party extensions.
878 * @param {boolean} force Skip nag check1127 * @param {boolean} force Skip nag check
@@ -891,21 +1140,26 @@ async function checkForExtensionUpdates(force) {
891 localStorage.setItem(STORAGE_NAG_KEY, currentDate);1140 localStorage.setItem(STORAGE_NAG_KEY, currentDate);
892 }1141 }
8931142
1143 const isCurrentUserAdmin = isAdmin();
894 const updatesAvailable = [];1144 const updatesAvailable = [];
895 const promises = [];1145 const promises = [];
8961146
897 for (const [id, manifest] of Object.entries(manifests)) {1147 for (const [id, manifest] of Object.entries(manifests)) {
1148 const isGlobal = getExtensionType(id) === 'global';
1149 if (isGlobal && !isCurrentUserAdmin) {
1150 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
1151 continue;
1152 }
1153
898 if (manifest.auto_update && id.startsWith('third-party')) {1154 if (manifest.auto_update && id.startsWith('third-party')) {
899 const promise = new Promise(async (resolve, reject) => {1155 const promise = enqueueVersionCheck(async () => {
900 try {1156 try {
901 const data = await getExtensionVersion(id.replace('third-party', ''));1157 const data = await getExtensionVersion(id.replace('third-party', ''));
902 if (data.isUpToDate === false) {1158 if (!data.isUpToDate) {
903 updatesAvailable.push(manifest.display_name);1159 updatesAvailable.push(manifest.display_name);
904 }1160 }
905 resolve();
906 } catch (error) {1161 } catch (error) {
907 console.error('Error checking for extension updates', error);1162 console.error('Error checking for extension updates', error);
908 reject();
909 }1163 }
910 });1164 });
911 promises.push(promise);1165 promises.push(promise);
@@ -930,8 +1184,14 @@ async function autoUpdateExtensions(forceAll) {
930 }1184 }
9311185
932 const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 });1186 const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 });
1187 const isCurrentUserAdmin = isAdmin();
933 const promises = [];1188 const promises = [];
934 for (const [id, manifest] of Object.entries(manifests)) {1189 for (const [id, manifest] of Object.entries(manifests)) {
1190 const isGlobal = getExtensionType(id) === 'global';
1191 if (isGlobal && !isCurrentUserAdmin) {
1192 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
1193 continue;
1194 }
935 if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) {1195 if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) {
936 console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`);1196 console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`);
937 promises.push(updateExtension(id.replace('third-party', ''), true));1197 promises.push(updateExtension(id.replace('third-party', ''), true));
@@ -947,7 +1207,7 @@ async function autoUpdateExtensions(forceAll) {
947 * @param {number} contextSize Context size1207 * @param {number} contextSize Context size
948 * @returns {Promise<boolean>} True if generation should be aborted1208 * @returns {Promise<boolean>} True if generation should be aborted
949 */1209 */
950async function runGenerationInterceptors(chat, contextSize) {1210export async function runGenerationInterceptors(chat, contextSize) {
951 let aborted = false;1211 let aborted = false;
952 let exitImmediately = false;1212 let exitImmediately = false;
9531213
@@ -956,11 +1216,11 @@ async function runGenerationInterceptors(chat, contextSize) {
956 exitImmediately = immediately;1216 exitImmediately = immediately;
957 };1217 };
9581218
959 for (const manifest of Object.values(manifests).sort((a, b) => a.loading_order - b.loading_order)) {1219 for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifests(a, b))) {
960 const interceptorKey = manifest.generate_interceptor;1220 const interceptorKey = manifest.generate_interceptor;
961 if (typeof window[interceptorKey] === 'function') {1221 if (typeof globalThis[interceptorKey] === 'function') {
962 try {1222 try {
963 await window[interceptorKey](chat, contextSize, abort);1223 await globalThis[interceptorKey](chat, contextSize, abort);
964 } catch (e) {1224 } catch (e) {
965 console.error(`Failed running interceptor for ${manifest.display_name}`, e);1225 console.error(`Failed running interceptor for ${manifest.display_name}`, e);
966 }1226 }
@@ -1033,8 +1293,23 @@ export async function writeExtensionField(characterId, key, value) {
1033 * @returns {Promise<void>}1293 * @returns {Promise<void>}
1034 */1294 */
1035export async function openThirdPartyExtensionMenu(suggestUrl = '') {1295export async function openThirdPartyExtensionMenu(suggestUrl = '') {
1036 const html = await renderTemplateAsync('installExtension');1296 const isCurrentUserAdmin = isAdmin();
1037 const input = await callGenericPopup(html, POPUP_TYPE.INPUT, suggestUrl ?? '');1297 const html = await renderTemplateAsync('installExtension', { isCurrentUserAdmin });
1298 const okButton = isCurrentUserAdmin ? t`Install just for me` : t`Install`;
1299
1300 let global = false;
1301 const installForAllButton = {
1302 text: t`Install for all users`,
1303 appendAtEnd: false,
1304 action: async () => {
1305 global = true;
1306 await popup.complete(POPUP_RESULT.AFFIRMATIVE);
1307 },
1308 };
1309
1310 const customButtons = isCurrentUserAdmin ? [installForAllButton] : [];
1311 const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons });
1312 const input = await popup.show();
10381313
1039 if (!input) {1314 if (!input) {
1040 console.debug('Extension install cancelled');1315 console.debug('Extension install cancelled');
@@ -1042,11 +1317,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
1042 }1317 }
10431318
1044 const url = String(input).trim();1319 const url = String(input).trim();
1045 await installExtension(url);1320 await installExtension(url, global);
1046}1321}
10471322
1048
1049
1050export async function initExtensions() {1323export async function initExtensions() {
1051 await addExtensionsButtonAndMenu();1324 await addExtensionsButtonAndMenu();
1052 $('#extensionsMenuButton').css('display', 'flex');1325 $('#extensionsMenuButton').css('display', 'flex');
@@ -1055,10 +1328,11 @@ export async function initExtensions() {
1055 $('#extensions_autoconnect').on('input', autoConnectInputHandler);1328 $('#extensions_autoconnect').on('input', autoConnectInputHandler);
1056 $('#extensions_details').on('click', showExtensionsDetails);1329 $('#extensions_details').on('click', showExtensionsDetails);
1057 $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler);1330 $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler);
1058 $(document).on('click', '.toggle_disable', onDisableExtensionClick);1331 $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick);
1059 $(document).on('click', '.toggle_enable', onEnableExtensionClick);1332 $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick);
1060 $(document).on('click', '.btn_update', onUpdateClick);1333 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
1061 $(document).on('click', '.btn_delete', onDeleteClick);1334 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
1335 $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
10621336
1063 /**1337 /**
1064 * Handles the click event for the third-party extension import button.1338 * Handles the click event for the third-party extension import button.
public/scripts/extensions/third-party/.gitkeep+0 -0
public/scripts/user.js+1 -1
@@ -31,7 +31,7 @@ export async function setUserControls(isEnabled) {
31 * Check if the current user is an admin.31 * Check if the current user is an admin.
32 * @returns {boolean} True if the current user is an admin32 * @returns {boolean} True if the current user is an admin
33 */33 */
34function isAdmin() {34export function isAdmin() {
35 if (!currentUser) {35 if (!currentUser) {
36 return false;36 return false;
37 }37 }
src/constants.js+1 -0
@@ -3,6 +3,7 @@ export const PUBLIC_DIRECTORIES = {
3 backups: 'backups/',3 backups: 'backups/',
4 sounds: 'public/sounds',4 sounds: 'public/sounds',
5 extensions: 'public/scripts/extensions',5 extensions: 'public/scripts/extensions',
6 globalExtensions: 'public/scripts/extensions/third-party',
6};7};
78
8export const SETTINGS_FILE = 'settings.json';9export const SETTINGS_FILE = 'settings.json';
src/endpoints/extensions.js+117 -32
@@ -73,8 +73,19 @@ router.post('/install', jsonParser, async (request, response) => {
73 fs.mkdirSync(path.join(request.user.directories.extensions));73 fs.mkdirSync(path.join(request.user.directories.extensions));
74 }74 }
7575
76 const url = request.body.url;76 if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
77 const extensionPath = path.join(request.user.directories.extensions, path.basename(url, '.git'));77 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
78 }
79
80 const { url, global } = request.body;
81
82 if (global && !request.user.profile.admin) {
83 console.warn(`User ${request.user.profile.handle} does not have permission to install global extensions.`);
84 return response.status(403).send('Forbidden: No permission to install global extensions.');
85 }
86
87 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
88 const extensionPath = path.join(basePath, sanitize(path.basename(url, '.git')));
7889
79 if (fs.existsSync(extensionPath)) {90 if (fs.existsSync(extensionPath)) {
80 return response.status(409).send(`Directory already exists at ${extensionPath}`);91 return response.status(409).send(`Directory already exists at ${extensionPath}`);
@@ -83,10 +94,8 @@ router.post('/install', jsonParser, async (request, response) => {
83 await git.clone(url, extensionPath, { '--depth': 1 });94 await git.clone(url, extensionPath, { '--depth': 1 });
84 console.log(`Extension has been cloned at ${extensionPath}`);95 console.log(`Extension has been cloned at ${extensionPath}`);
8596
86
87 const { version, author, display_name } = await getManifest(extensionPath);97 const { version, author, display_name } = await getManifest(extensionPath);
8898
89
90 return response.send({ version, author, display_name, extensionPath });99 return response.send({ version, author, display_name, extensionPath });
91 } catch (error) {100 } catch (error) {
92 console.log('Importing custom content failed', error);101 console.log('Importing custom content failed', error);
@@ -112,8 +121,15 @@ router.post('/update', jsonParser, async (request, response) => {
112 }121 }
113122
114 try {123 try {
115 const extensionName = request.body.extensionName;124 const { extensionName, global } = request.body;
116 const extensionPath = path.join(request.user.directories.extensions, extensionName);125
126 if (global && !request.user.profile.admin) {
127 console.warn(`User ${request.user.profile.handle} does not have permission to update global extensions.`);
128 return response.status(403).send('Forbidden: No permission to update global extensions.');
129 }
130
131 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
132 const extensionPath = path.join(basePath, extensionName);
117133
118 if (!fs.existsSync(extensionPath)) {134 if (!fs.existsSync(extensionPath)) {
119 return response.status(404).send(`Directory does not exist at ${extensionPath}`);135 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -122,7 +138,6 @@ router.post('/update', jsonParser, async (request, response) => {
122 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);138 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
123 const currentBranch = await git.cwd(extensionPath).branch();139 const currentBranch = await git.cwd(extensionPath).branch();
124 if (!isUpToDate) {140 if (!isUpToDate) {
125
126 await git.cwd(extensionPath).pull('origin', currentBranch.current);141 await git.cwd(extensionPath).pull('origin', currentBranch.current);
127 console.log(`Extension has been updated at ${extensionPath}`);142 console.log(`Extension has been updated at ${extensionPath}`);
128 } else {143 } else {
@@ -140,6 +155,50 @@ router.post('/update', jsonParser, async (request, response) => {
140 }155 }
141});156});
142157
158router.post('/move', jsonParser, async (request, response) => {
159 try {
160 const { extensionName, source, destination } = request.body;
161
162 if (!extensionName || !source || !destination) {
163 return response.status(400).send('Bad Request. Not all required parameters are provided.');
164 }
165
166 if (!request.user.profile.admin) {
167 console.warn(`User ${request.user.profile.handle} does not have permission to move extensions.`);
168 return response.status(403).send('Forbidden: No permission to move extensions.');
169 }
170
171 const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
172 const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
173 const sourcePath = path.join(sourceDirectory, sanitize(extensionName));
174 const destinationPath = path.join(destinationDirectory, sanitize(extensionName));
175
176 if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
177 console.error(`Source directory does not exist at ${sourcePath}`);
178 return response.status(404).send('Source directory does not exist.');
179 }
180
181 if (fs.existsSync(destinationPath)) {
182 console.error(`Destination directory already exists at ${destinationPath}`);
183 return response.status(409).send('Destination directory already exists.');
184 }
185
186 if (source === destination) {
187 console.error('Source and destination directories are the same');
188 return response.status(409).send('Source and destination directories are the same.');
189 }
190
191 fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true });
192 fs.rmSync(sourcePath, { recursive: true, force: true });
193 console.log(`Extension has been moved from ${sourcePath} to ${destinationPath}`);
194
195 return response.sendStatus(204);
196 } catch (error) {
197 console.log('Moving extension failed', error);
198 return response.status(500).send('Internal Server Error. Try again later.');
199 }
200});
201
143/**202/**
144 * HTTP POST handler function to get the current git commit hash and branch name for a given extension.203 * HTTP POST handler function to get the current git commit hash and branch name for a given extension.
145 * It checks whether the repository is up-to-date with the remote, and returns the status along with204 * It checks whether the repository is up-to-date with the remote, and returns the status along with
@@ -157,19 +216,28 @@ router.post('/version', jsonParser, async (request, response) => {
157 }216 }
158217
159 try {218 try {
160 const extensionName = request.body.extensionName;219 const { extensionName, global } = request.body;
161 const extensionPath = path.join(request.user.directories.extensions, extensionName);220 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
221 const extensionPath = path.join(basePath, sanitize(extensionName));
162222
163 if (!fs.existsSync(extensionPath)) {223 if (!fs.existsSync(extensionPath)) {
164 return response.status(404).send(`Directory does not exist at ${extensionPath}`);224 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
165 }225 }
166226
227 let currentCommitHash;
228 try {
229 currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
230 } catch (error) {
231 // it is not a git repo, or has no commits yet, or is a bare repo
232 // not possible to update it, most likely can't get the branch name either
233 return response.send({ currentBranchName: null, currentCommitHash, isUpToDate: true, remoteUrl: null });
234 }
235
167 const currentBranch = await git.cwd(extensionPath).branch();236 const currentBranch = await git.cwd(extensionPath).branch();
168 // get only the working branch237 // get only the working branch
169 const currentBranchName = currentBranch.current;238 const currentBranchName = currentBranch.current;
170 await git.cwd(extensionPath).fetch('origin');239 await git.cwd(extensionPath).fetch('origin');
171 const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);240 console.log(extensionName, currentBranchName, currentCommitHash);
172 console.log(currentBranch, currentCommitHash);
173 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);241 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
174242
175 return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });243 return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
@@ -193,11 +261,16 @@ router.post('/delete', jsonParser, async (request, response) => {
193 return response.status(400).send('Bad Request: extensionName is required in the request body.');261 return response.status(400).send('Bad Request: extensionName is required in the request body.');
194 }262 }
195263
196 // Sanitize the extension name to prevent directory traversal
197 const extensionName = sanitize(request.body.extensionName);
198
199 try {264 try {
200 const extensionPath = path.join(request.user.directories.extensions, extensionName);265 const { extensionName, global } = request.body;
266
267 if (global && !request.user.profile.admin) {
268 console.warn(`User ${request.user.profile.handle} does not have permission to delete global extensions.`);
269 return response.status(403).send('Forbidden: No permission to delete global extensions.');
270 }
271
272 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
273 const extensionPath = path.join(basePath, sanitize(extensionName));
201274
202 if (!fs.existsSync(extensionPath)) {275 if (!fs.existsSync(extensionPath)) {
203 return response.status(404).send(`Directory does not exist at ${extensionPath}`);276 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -219,26 +292,38 @@ router.post('/delete', jsonParser, async (request, response) => {
219 * If the folder is called third-party, search for subfolders instead292 * If the folder is called third-party, search for subfolders instead
220 */293 */
221router.get('/discover', jsonParser, function (request, response) {294router.get('/discover', jsonParser, function (request, response) {
222 // get all folders in the extensions folder, except third-party
223 const extensions = fs
224 .readdirSync(PUBLIC_DIRECTORIES.extensions)
225 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
226 .filter(f => f !== 'third-party');
227
228 // get all folders in the third-party folder, if it exists
229
230 if (!fs.existsSync(path.join(request.user.directories.extensions))) {295 if (!fs.existsSync(path.join(request.user.directories.extensions))) {
231 return response.send(extensions);296 fs.mkdirSync(path.join(request.user.directories.extensions));
232 }297 }
233298
234 const thirdPartyExtensions = fs299 if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
235 .readdirSync(path.join(request.user.directories.extensions))300 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
236 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory());301 }
237
238 // add the third-party extensions to the extensions array
239 extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`));
240 console.log(extensions);
241302
303 // Get all folders in system extensions folder, excluding third-party
304 const builtInExtensions = fs
305 .readdirSync(PUBLIC_DIRECTORIES.extensions)
306 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
307 .filter(f => f !== 'third-party')
308 .map(f => ({ type: 'system', name: f }));
242309
243 return response.send(extensions);310 // Get all folders in local extensions folder
311 const userExtensions = fs
312 .readdirSync(path.join(request.user.directories.extensions))
313 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())
314 .map(f => ({ type: 'local', name: `third-party/${f}` }));
315
316 // Get all folders in global extensions folder
317 // In case of a conflict, the extension will be loaded from the user folder
318 const globalExtensions = fs
319 .readdirSync(PUBLIC_DIRECTORIES.globalExtensions)
320 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory())
321 .map(f => ({ type: 'global', name: `third-party/${f}` }))
322 .filter(f => !userExtensions.some(e => e.name === f.name));
323
324 // Combine all extensions
325 const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions];
326 console.log(allExtensions);
327
328 return response.send(allExtensions);
244});329});
src/users.js+29 -1
@@ -783,6 +783,34 @@ function createRouteHandler(directoryFn) {
783}783}
784784
785/**785/**
786 * Creates a route handler for serving extensions.
787 * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
788 * @returns {import('express').RequestHandler}
789 */
790function createExtensionsRouteHandler(directoryFn) {
791 return async (req, res) => {
792 try {
793 const directory = directoryFn(req);
794 const filePath = decodeURIComponent(req.params[0]);
795
796 const existsLocal = fs.existsSync(path.join(directory, filePath));
797 if (existsLocal) {
798 return res.sendFile(filePath, { root: directory });
799 }
800
801 const existsGlobal = fs.existsSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, filePath));
802 if (existsGlobal) {
803 return res.sendFile(filePath, { root: PUBLIC_DIRECTORIES.globalExtensions });
804 }
805
806 return res.sendStatus(404);
807 } catch (error) {
808 return res.sendStatus(500);
809 }
810 };
811}
812
813/**
786 * Verifies that the current user is an admin.814 * Verifies that the current user is an admin.
787 * @param {import('express').Request} request Request object815 * @param {import('express').Request} request Request object
788 * @param {import('express').Response} response Response object816 * @param {import('express').Response} response Response object
@@ -872,4 +900,4 @@ router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.a
872router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));900router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));
873router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));901router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
874router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));902router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
875router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions));903router.use('/scripts/extensions/third-party/*', createExtensionsRouteHandler(req => req.user.directories.extensions));