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

00006aa072b9cf3bd53e5253419c876a7274102d

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

Signed
10 files changed, +646 -213Showing whitespace changes
.dockerignore+1 -0
@@ -12,3 +12,4 @@ access.log
1212/data
1313/cache
1414.DS_Store
15+/public/scripts/extensions/third-party
.gitignore+2 -0
@@ -50,3 +50,5 @@ public/css/user.css
5050/default/scaffold
5151public/scripts/extensions/third-party
5252/certs
53+.aider*
54+.env
.npmignore+1 -0
@@ -11,3 +11,4 @@ access.log
1111.github
1212.vscode
1313.git
14+/public/scripts/extensions/third-party
public/css/extensions-panel.css+47 -6
@@ -65,7 +65,7 @@ label[for="extensions_autoconnect"] {
6565}
6666
6767.extensions_info .extension_enabled {
6868 colorfont-weight: greenbold;
6969}
7070
7171.extensions_info .extension_disabled {
@@ -76,13 +76,44 @@ label[for="extensions_autoconnect"] {
7676 color: gray;
7777}
7878
79-input.extension_missing[type="checkbox"] {
79+.extensions_info .extension_modules {
8080 opacityfont-size: 0.58em;
81+ font-weight: normal;
8182}
8283
8384#extensions_list.extensions_info .disabledextension_block {
8485 text-decorationdisplay: line-throughflex;
8586 colorflex-wrap: lightgraynowrap;
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+
115+input.extension_missing[type="checkbox"] {
116+ opacity: 0.5;
86117}
87118
88119.update-button {
@@ -105,3 +136,13 @@ input.extension_missing[type="checkbox"] {
105136#extensionsMenu>div.extension_container:empty {
106137 display: none;
107138}
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+447 -173
@@ -4,29 +4,57 @@ import { eventSource, event_types, saveSettings, saveSettingsDebounced, getReque
44import { showLoader } from './loader.js';
55import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
66import { renderTemplate, renderTemplateAsync } from './templates.js';
77import { delay, isSubsetOf, setValueByPath } from './utils.js';
88import { getContext } from './st-context.js';
9+import { isAdmin } from './user.js';
10+import { t } from './i18n.js';
11+import { debounce_timeout } from './constants.js';
12+
913export {
1014 getContext,
1115 getApiUrl,
12- loadExtensionSettings,
13- runGenerationInterceptors,
14- doExtrasFetch,
15- modules,
16- extension_settings,
17- ModuleWorkerWrapper,
1816};
1917
2018/** @type {string[]} */
2119export 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+ */
26+export let extensionTypes = {};
27+
28+/**
29+ * A list of active modules provided by the Extras API.
30+ * @type {string[]}
31+ */
32+export let modules = [];
33+
34+/**
35+ * A set of active extensions.
36+ * @type {Set<string>}
37+ */
38+let activeExtensions = new Set();
39+
40+const getApiUrl = () => extension_settings.apiUrl;
41+const sortManifests = (a, b) => a.loading_order - b.loading_order;
42+let connectedToApi = false;
43+
44+/**
45+ * Holds manifest data for each extension.
46+ * @type {Record<string, object>}
47+ */
2348let manifests = {};
24-const defaultUrl = 'http://localhost:5100';
2549
26-let saveMetadataTimeout = null;
50+/**
51+ * Default URL for the Extras API.
52+ */
53+const defaultUrl = 'http://localhost:5100';
2754
2855let requiresReload = false;
2956let stateChanged = false;
57+let saveMetadataTimeout = null;
3058
3159export function saveMetadataDebounced() {
3260 const context = getContext();
@@ -51,9 +79,9 @@ export function saveMetadataDebounced() {
5179 }
5280
5381 console.debug('Saving metadata...');
5482 await newContext.saveMetadata();
5583 console.debug('Saved metadata...');
5684 }, 1000debounce_timeout.relaxed);
5785}
5886
5987/**
@@ -83,7 +111,7 @@ export function renderExtensionTemplateAsync(extensionName, templateId, template
83111}
84112
85113// Disables parallel updates
86114export class ModuleWorkerWrapper {
87115 constructor(callback) {
88116 this.isBusy = false;
89117 this.callback = callback;
@@ -107,7 +135,7 @@ class ModuleWorkerWrapper {
107135 }
108136}
109137
110138export const extension_settings = {
111139 apiUrl: defaultUrl,
112140 apiKey: '',
113141 autoConnect: false,
@@ -172,12 +200,6 @@ const extension_settings = {
172200 disabled_attachments: [],
173201};
174202
175-let modules = [];
176-let activeExtensions = new Set();
177-
178-const getApiUrl = () => extension_settings.apiUrl;
179-let connectedToApi = false;
180-
181203function showHideExtensionsMenu() {
182204 // Get the number of menu items that are not hidden
183205 const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0;
@@ -194,7 +216,23 @@ function showHideExtensionsMenu() {
194216// Periodically check for new extensions
195217const menuInterval = setInterval(showHideExtensionsMenu, 1000);
196218
197-async 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+ */
224+function 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+ */
235+export async function doExtrasFetch(endpoint, args = {}) {
198236 if (!args) {
199237 args = {};
200238 }
@@ -213,10 +251,13 @@ async function doExtrasFetch(endpoint, args) {
213251 });
214252 }
215253
216254 const response =return await fetch(endpoint, args);
217- return response;
218255}
219256
257+/**
258+ * Discovers extensions from the API.
259+ * @returns {Promise<{name: string, type: string}[]>}
260+ */
220261async function discoverExtensions() {
221262 try {
222263 const response = await fetch('/api/extensions/discover');
@@ -245,6 +286,11 @@ function onEnableExtensionClick() {
245286 enableExtension(name, false);
246287}
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+ */
248294export async function enableExtension(name, reload = true) {
249295 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);
250296 stateChanged = true;
@@ -256,6 +302,11 @@ export async function enableExtension(name, reload = true) {
256302 }
257303}
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+ */
259310export async function disableExtension(name, reload = true) {
260311 extension_settings.disabledExtensions.push(name);
261312 stateChanged = true;
@@ -267,6 +318,11 @@ export async function disableExtension(name, reload = true) {
267318 }
268319}
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+ */
270326async function getManifests(names) {
271327 const obj = {};
272328 const promises = [];
@@ -294,43 +350,36 @@ async function getManifests(names) {
294350 return obj;
295351}
296352
353+/**
354+ * Tries to activate all available extensions that are not already active.
355+ * @returns {Promise<void>}
356+ */
297357async function activateExtensions() {
298358 const extensions = Object.entries(manifests).sort((a, b) => sortManifests(a[1].loading_order -, b[1].loading_order));
299359 const promises = [];
300360
301361 for (let entry of extensions) {
302362 const name = entry[0];
303363 const manifest = entry[1];
304- const elementExists = document.getElementById(name) !== null;
305364
306365 if (elementExists || activeExtensions.has(name)) {
307366 continue;
308367 }
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)) {
312- try {
313370 const isDisabled = extension_settings.disabledExtensions.includes(name);
314- const li = document.createElement('li');
315371
316372 if (meetsModuleRequirements && !isDisabled) {
373+ try {
374+ console.debug('Activating extension', name);
317375 const promise = Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]);
318376 await promise
319377 .then(() => activeExtensions.add(name))
320378 .catch(err => console.log('Could not activate extension: ' +, name, err));
321379 promises.push(promise);
322380 }
323- else {
324- li.classList.add('disabled');
325- }
326-
327- li.id = name;
328- li.innerText = manifest.display_name;
329-
330- $('#extensions_list').append(li);
331- }
332381 catch (error) {
333382 console.error(`'Could not activate extension:', ${name}`);
334383 console.error(error);
335384 }
336385 }
@@ -340,8 +389,8 @@ async function activateExtensions() {
340389}
341390
342391async function connectClickHandler() {
343392 const baseUrl = String($('#extensions_url').val());
344393 extension_settings.apiUrl = String(baseUrl);
345394 const testApiKey = $('#extensions_api_key').val();
346395 extension_settings.apiKey = String(testApiKey);
347396 saveSettingsDebounced();
@@ -401,21 +450,11 @@ function notifyUpdatesInputHandler() {
401450 }
402451}
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-
419458async function connectToApi(baseUrl) {
420459 if (!baseUrl) {
421460 return;
@@ -431,7 +470,7 @@ async function connectToApi(baseUrl) {
431470 const data = await getExtensionsResult.json();
432471 modules = data.modules;
433472 await activateExtensions();
434473 await eventSource.emit(event_types.EXTRAS_CONNECTED, modules);
435474 }
436475
437476 updateStatus(getExtensionsResult.ok);
@@ -441,16 +480,29 @@ async function connectToApi(baseUrl) {
441480 }
442481}
443482
483+/**
484+ * Updates the status of Extras API connection.
485+ * @param {boolean} success Whether the connection was successful
486+ */
444487function updateStatus(success) {
445488 connectedToApi = success;
446489 const _text = success ? 't`Connected to API'` : 't`Could not connect to API'`;
447490 const _class = success ? 'success' : 'failure';
448491 $('#extensions_status').text(_text);
449492 $('#extensions_status').attr('class', _class);
450493}
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+ */
452501function addExtensionStyle(name, manifest) {
453502 if (!manifest.css) {
503+ return Promise.resolve();
504+ }
505+
454506 return new Promise((resolve, reject) => {
455507 const url = `/scripts/extensions/${name}/${manifest.css}`;
456508
@@ -471,11 +523,17 @@ function addExtensionStyle(name, manifest) {
471523 });
472524}
473525
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+ */
532+function addExtensionScript(name, manifest) {
533+ if (!manifest.js) {
474534 return Promise.resolve();
475535 }
476536
477-function addExtensionScript(name, manifest) {
478- if (manifest.js) {
479537 return new Promise((resolve, reject) => {
480538 const url = `/scripts/extensions/${name}/${manifest.js}`;
481539 let ready = false;
@@ -487,11 +545,10 @@ function addExtensionScript(name, manifest) {
487545 script.src = url;
488546 script.async = true;
489547 script.onerror = function (err) {
490548 reject(err, script);
491549 };
492550 script.onload = script.onreadystatechange = function () {
493- // console.log(this.readyState); // uncomment this line to see which ready states are called.
551+ if (!ready) {
494- if (!ready && (!this.readyState || this.readyState == 'complete')) {
495552 ready = true;
496553 resolve();
497554 }
@@ -501,11 +558,6 @@ function addExtensionScript(name, manifest) {
501558 });
502559}
503560
504- return Promise.resolve();
505-}
506-
507-
508-
509561/**
510562 * Generates HTML string for displaying an extension in the UI.
511563 *
@@ -515,64 +567,85 @@ function addExtensionScript(name, manifest) {
515567 * @param {boolean} isDisabled - Whether the extension is disabled or not.
516568 * @param {boolean} isExternal - Whether the extension is external or not.
517569 * @param {string} checkboxClass - The class for the checkbox HTML element.
518570 * @return {Promise<string>} - The HTML string that represents the extension.
519571 */
520572async function 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();
521589 const displayName = manifest.display_name;
522590 letconst displayVersion = manifest.version ? ` v${manifest.version}` :|| '';
523- let isUpToDate = true;
591+ const externalId = name.replace('third-party', '');
524- let updateButton = '';
525592 let originHtml = '';
526593 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">`;
537595 }
538596
539597 let toggleElement = isActive || isDisabled ?
540598 `<input type="checkbox" title="Click to toggle" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :
541599 `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;
542600
543601 let deleteButton = isExternal ? `<span class="delete-button"><button class="btn_delete menu_button" data-name="${name.replace('third-party', '')externalId}" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button></span>` : '';
544-
602+ 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 repo
603+ 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>` : '';
546-
604+ 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
560606 if (isActive && Array.isArray(manifest.optional)) {
561607 const optional = new Set(manifest.optional);
562608 modules.forEach(x => optional.delete(x));
563609 if (optional.size > 0) {
564610 const optionalString = DOMPurify.sanitize([...optional].join(', '));
565611 extensionHtmlmodulesInfo += `<pdiv class="extension_modules">Optional modules: <span class="optional">${optionalString}</span></pdiv>`;
566612 }
567613 } else if (!isDisabled) { // Neither active nor disabled
568614 const requirements = new Set(manifest.requires);
569615 modules.forEach(x => requirements.delete(x));
570616 if (requirements.size > 0) {
571617 const requirementsString = DOMPurify.sanitize([...requirements].join(', '));
572618 extensionHtmlmodulesInfo += `<pdiv class="extension_modules">Missing modules: <span class="failure">${requirementsString}</span></pdiv>`;
573619 }
574620 }
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+
576649 return extensionHtml;
577650}
578651
@@ -580,9 +653,9 @@ async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExt
580653 * Gets extension data and generates the corresponding HTML for displaying the extension.
581654 *
582655 * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest.
583656 * @return {Promise<object>} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string.
584657 */
585658async function getExtensionData(extension) {
586659 const name = extension[0];
587660 const manifest = extension[1];
588661 const isActive = activeExtensions.has(name);
@@ -591,7 +664,7 @@ async function getExtensionData(extension) {
591664
592665 const checkboxClass = isDisabled ? 'checkbox_disabled' : '';
593666
594667 const extensionHtml = await generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
595668
596669 return { isExternal, extensionHtml };
597670}
@@ -614,42 +687,38 @@ function getModuleInformation() {
614687 * Generates the HTML strings for all extensions and displays them in a popup.
615688 */
616689async function showExtensionsDetails() {
690+ const abortController = new AbortController();
617691 let popupPromise;
618692 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">
622703 <i class="fa-solid fa-spinner fa-spin"></i>
623704 <span>Loading third-party extensions... Please wait...</span>
624705 </h3div>`);
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- }
633710
634711 promisesextensions.forEach(promisevalue => {
635- promise.then(value => {
636712 const { isExternal, extensionHtml } = value;
637713 const container = isExternal ? htmlExternal : htmlDefault;
638714 container.append(extensionHtml);
639715 });
640- });
641-
642- Promise.allSettled(promises).then(() => {
643- htmlLoading.remove();
644- htmlExternal.removeClass('opacity50p');
645- });
646716
647717 const html = $('<div></div>')
648718 .addClass('extensions_info')
649- .append(getModuleInformation())
650719 .append(htmlDefault)
651720 .append(htmlLoadinghtmlExternal)
652721 .append(htmlExternalgetModuleInformation());
653722
654723 /** @type {import('./popup.js').CustomPopupButton} */
655724 const updateAllButton = {
@@ -662,12 +731,6 @@ async function showExtensionsDetails() {
662731 },
663732 };
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-
671734 let waitingForSave = false;
672735
673736 const popup = new Popup(html, POPUP_TYPE.TEXT, '', {
@@ -682,7 +745,7 @@ async function showExtensionsDetails() {
682745 }
683746 if (stateChanged) {
684747 waitingForSave = true;
685748 const toast = toastr.info('t`The page will be reloaded shortly...'`, 't`Extensions state changed'`);
686749 await saveSettings();
687750 toastr.clear(toast);
688751 waitingForSave = false;
@@ -692,12 +755,15 @@ async function showExtensionsDetails() {
692755 },
693756 });
694757 popupPromise = popup.show();
758+ popup.content.scrollTop = initialScrollTop;
759+ checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove());
695760 } catch (error) {
696761 toastr.error('t`Error loading extensions. See browser console for details.'`);
697762 console.error(error);
698763 }
699764 if (popupPromise) {
700765 await popupPromise;
766+ abortController.abort();
701767 }
702768 if (requiresReload) {
703769 showLoader();
@@ -705,17 +771,26 @@ async function showExtensionsDetails() {
705771 }
706772}
707773
708-
709774/**
710775 * Handles the click event for the update button of an extension.
711776 * This function makes a POST request to '/update_extensionapi/extensions/update' with the extension's name.
712777 * If the extension is already up to date, it displays a success message.
713778 * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash.
714779 */
715780async function onUpdateClick() {
781+ const isCurrentUserAdmin = isAdmin();
716782 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');
718791 await updateExtension(extensionName, false);
792+ // updateExtension eats the error, but we can at least stop the spinner
793+ icon.removeClass('fa-spin');
719794}
720795
721796/**
@@ -728,13 +803,23 @@ async function updateExtension(extensionName, quiet) {
728803 const response = await fetch('/api/extensions/update', {
729804 method: 'POST',
730805 headers: getRequestHeaders(),
731806 body: JSON.stringify({ extensionName }),
807+ extensionName,
808+ global: getExtensionType(extensionName) === 'global',
809+ }),
732810 });
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+
734819 const data = await response.json();
735820
736821 if (!quiet) {
737822 void showExtensionsDetails();
738823 }
739824
740825 if (data.isUpToDate) {
@@ -757,44 +842,122 @@ async function updateExtension(extensionName, quiet) {
757842 */
758843async function onDeleteClick() {
759844 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+
760852 // use callPopup to create a popup for the user to confirm before delete
761853 const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {});
762854 if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
763855 await deleteExtension(extensionName);
764856 }
765857}
766858
859+async 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+ */
893+async 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+ */
767924export async function deleteExtension(extensionName) {
768925 try {
769926 await fetch('/api/extensions/delete', {
770927 method: 'POST',
771928 headers: getRequestHeaders(),
772929 body: JSON.stringify({ extensionName }),
930+ extensionName,
931+ global: getExtensionType(extensionName) === 'global',
932+ }),
773933 });
774934 } catch (error) {
775935 console.error('Error:', error);
776936 }
777937
778938 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();
782940}
783941
784942/**
785943 * Fetches the version details of a specific extension.
786944 *
787945 * @param {string} extensionName - The name of the extension.
946+ * @param {AbortSignal} [abortSignal] - The signal to abort the operation.
788947 * @return {Promise<object>} - An object containing the extension's version details.
789948 * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl.
790949 * @throws {error} - If there is an error during the fetch operation, it logs the error to the console.
791950 */
792951async function getExtensionVersion(extensionName, abortSignal) {
793952 try {
794953 const response = await fetch('/api/extensions/version', {
795954 method: 'POST',
796955 headers: getRequestHeaders(),
797956 body: JSON.stringify({ extensionName }),
957+ extensionName,
958+ global: getExtensionType(extensionName) === 'global',
959+ }),
960+ signal: abortSignal,
798961 });
799962
800963 const data = await response.json();
@@ -807,22 +970,26 @@ async function getExtensionVersion(extensionName) {
807970/**
808971 * Installs a third-party extension via the API.
809972 * @param {string} url Extension repository URL
973+ * @param {boolean} global Is the extension global?
810974 * @returns {Promise<void>}
811975 */
812976export async function installExtension(url, global) {
813977 console.debug('Extension installation started', url);
814978
815979 toastr.info('t`Please wait...'`, 't`Installing extension'`);
816980
817981 const request = await fetch('/api/extensions/install', {
818982 method: 'POST',
819983 headers: getRequestHeaders(),
820984 body: JSON.stringify({ url }),
985+ url,
986+ global,
987+ }),
821988 });
822989
823990 if (!request.ok) {
824991 const text = await request.text();
825992 toastr.warning(text || request.statusText, 't`Extension installation failed'`, { timeOut: 5000 });
826993 console.error('Extension installation failed', request.status, request.statusText, text);
827994 return;
828995 }
@@ -840,7 +1007,7 @@ export async function installExtension(url) {
8401007 * @param {boolean} versionChanged Is this a version change?
8411008 * @param {boolean} enableAutoUpdate Enable auto-update
8421009 */
8431010export async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) {
8441011 if (settings.extension_settings) {
8451012 Object.assign(extension_settings, settings.extension_settings);
8461013 }
@@ -852,7 +1019,9 @@ async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate)
8521019
8531020 // Activate offline extensions
8541021 await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD);
8551022 extensionNamesconst extensions = await discoverExtensions();
1023+ extensionNames = extensions.map(x => x.name);
1024+ extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type]));
8561025 manifests = await getManifests(extensionNames);
8571026
8581027 if (versionChanged && enableAutoUpdate) {
@@ -873,6 +1042,86 @@ export function doDailyExtensionUpdatesCheck() {
8731042 }, 1);
8741043}
8751044
1045+const concurrencyLimit = 5;
1046+let activeRequestsCount = 0;
1047+const versionCheckQueue = [];
1048+
1049+function enqueueVersionCheck(fn) {
1050+ return new Promise((resolve, reject) => {
1051+ versionCheckQueue.push(() => fn().then(resolve).catch(reject));
1052+ processVersionCheckQueue();
1053+ });
1054+}
1055+
1056+function 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+ */
1073+async 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+
8761125/**
8771126 * Checks if there are updates available for 3rd-party extensions.
8781127 * @param {boolean} force Skip nag check
@@ -891,21 +1140,26 @@ async function checkForExtensionUpdates(force) {
8911140 localStorage.setItem(STORAGE_NAG_KEY, currentDate);
8921141 }
8931142
1143+ const isCurrentUserAdmin = isAdmin();
8941144 const updatesAvailable = [];
8951145 const promises = [];
8961146
8971147 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+
8981154 if (manifest.auto_update && id.startsWith('third-party')) {
8991155 const promise = new PromiseenqueueVersionCheck(async (resolve, reject) => {
9001156 try {
9011157 const data = await getExtensionVersion(id.replace('third-party', ''));
9021158 if (!data.isUpToDate === false) {
9031159 updatesAvailable.push(manifest.display_name);
9041160 }
905- resolve();
9061161 } catch (error) {
9071162 console.error('Error checking for extension updates', error);
908- reject();
9091163 }
9101164 });
9111165 promises.push(promise);
@@ -930,8 +1184,14 @@ async function autoUpdateExtensions(forceAll) {
9301184 }
9311185
9321186 const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 });
1187+ const isCurrentUserAdmin = isAdmin();
9331188 const promises = [];
9341189 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+ }
9351195 if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) {
9361196 console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`);
9371197 promises.push(updateExtension(id.replace('third-party', ''), true));
@@ -947,7 +1207,7 @@ async function autoUpdateExtensions(forceAll) {
9471207 * @param {number} contextSize Context size
9481208 * @returns {Promise<boolean>} True if generation should be aborted
9491209 */
9501210export async function runGenerationInterceptors(chat, contextSize) {
9511211 let aborted = false;
9521212 let exitImmediately = false;
9531213
@@ -956,11 +1216,11 @@ async function runGenerationInterceptors(chat, contextSize) {
9561216 exitImmediately = immediately;
9571217 };
9581218
9591219 for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifests(a.loading_order -, b.loading_order))) {
9601220 const interceptorKey = manifest.generate_interceptor;
9611221 if (typeof windowglobalThis[interceptorKey] === 'function') {
9621222 try {
9631223 await windowglobalThis[interceptorKey](chat, contextSize, abort);
9641224 } catch (e) {
9651225 console.error(`Failed running interceptor for ${manifest.display_name}`, e);
9661226 }
@@ -1033,8 +1293,23 @@ export async function writeExtensionField(characterId, key, value) {
10331293 * @returns {Promise<void>}
10341294 */
10351295export async function openThirdPartyExtensionMenu(suggestUrl = '') {
10361296 const htmlisCurrentUserAdmin = await renderTemplateAsyncisAdmin('installExtension');
10371297 const inputhtml = await callGenericPopuprenderTemplateAsync(html, POPUP_TYPE.INPUT'installExtension', suggestUrl{ ??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
10391314 if (!input) {
10401315 console.debug('Extension install cancelled');
@@ -1042,11 +1317,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
10421317 }
10431318
10441319 const url = String(input).trim();
10451320 await installExtension(url, global);
10461321}
10471322
1048-
1049-
10501323export async function initExtensions() {
10511324 await addExtensionsButtonAndMenu();
10521325 $('#extensionsMenuButton').css('display', 'flex');
@@ -1055,10 +1328,11 @@ export async function initExtensions() {
10551328 $('#extensions_autoconnect').on('input', autoConnectInputHandler);
10561329 $('#extensions_details').on('click', showExtensionsDetails);
10571330 $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler);
10581331 $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick);
10591332 $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick);
10601333 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
10611334 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
1335+ $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
10621336
10631337 /**
10641338 * 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) {
3131 * Check if the current user is an admin.
3232 * @returns {boolean} True if the current user is an admin
3333 */
3434export function isAdmin() {
3535 if (!currentUser) {
3636 return false;
3737 }
src/constants.js+1 -0
@@ -3,6 +3,7 @@ export const PUBLIC_DIRECTORIES = {
33 backups: 'backups/',
44 sounds: 'public/sounds',
55 extensions: 'public/scripts/extensions',
6+ globalExtensions: 'public/scripts/extensions/third-party',
67};
78
89export const SETTINGS_FILE = 'settings.json';
src/endpoints/extensions.js+117 -32
@@ -73,8 +73,19 @@ router.post('/install', jsonParser, async (request, response) => {
7373 fs.mkdirSync(path.join(request.user.directories.extensions));
7474 }
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
7990 if (fs.existsSync(extensionPath)) {
8091 return response.status(409).send(`Directory already exists at ${extensionPath}`);
@@ -83,10 +94,8 @@ router.post('/install', jsonParser, async (request, response) => {
8394 await git.clone(url, extensionPath, { '--depth': 1 });
8495 console.log(`Extension has been cloned at ${extensionPath}`);
8596
86-
8797 const { version, author, display_name } = await getManifest(extensionPath);
8898
89-
9099 return response.send({ version, author, display_name, extensionPath });
91100 } catch (error) {
92101 console.log('Importing custom content failed', error);
@@ -112,8 +121,15 @@ router.post('/update', jsonParser, async (request, response) => {
112121 }
113122
114123 try {
115124 const { extensionName, global } = request.body.extensionName;
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
118134 if (!fs.existsSync(extensionPath)) {
119135 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -122,7 +138,6 @@ router.post('/update', jsonParser, async (request, response) => {
122138 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
123139 const currentBranch = await git.cwd(extensionPath).branch();
124140 if (!isUpToDate) {
125-
126141 await git.cwd(extensionPath).pull('origin', currentBranch.current);
127142 console.log(`Extension has been updated at ${extensionPath}`);
128143 } else {
@@ -140,6 +155,50 @@ router.post('/update', jsonParser, async (request, response) => {
140155 }
141156});
142157
158+router.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+
143202/**
144203 * HTTP POST handler function to get the current git commit hash and branch name for a given extension.
145204 * 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) => {
157216 }
158217
159218 try {
160219 const { extensionName, global } = request.body.extensionName;
161220 const extensionPathbasePath = pathglobal ? PUBLIC_DIRECTORIES.join(globalExtensions : request.user.directories.extensions, extensionName);
221+ const extensionPath = path.join(basePath, sanitize(extensionName));
162222
163223 if (!fs.existsSync(extensionPath)) {
164224 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
165225 }
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+
167236 const currentBranch = await git.cwd(extensionPath).branch();
168237 // get only the working branch
169238 const currentBranchName = currentBranch.current;
170239 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);
173241 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
174242
175243 return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
@@ -193,11 +261,16 @@ router.post('/delete', jsonParser, async (request, response) => {
193261 return response.status(400).send('Bad Request: extensionName is required in the request body.');
194262 }
195263
196- // Sanitize the extension name to prevent directory traversal
197- const extensionName = sanitize(request.body.extensionName);
198-
199264 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
202275 if (!fs.existsSync(extensionPath)) {
203276 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -219,26 +292,38 @@ router.post('/delete', jsonParser, async (request, response) => {
219292 * If the folder is called third-party, search for subfolders instead
220293 */
221294router.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-
230295 if (!fs.existsSync(path.join(request.user.directories.extensions))) {
231- return response.send(extensions);
296+ fs.mkdirSync(path.join(request.user.directories.extensions));
232297 }
233298
234- const thirdPartyExtensions = fs
299+ 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);
244329});
src/users.js+29 -1
@@ -783,6 +783,34 @@ function createRouteHandler(directoryFn) {
783783}
784784
785785/**
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+ */
790+function 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+/**
786814 * Verifies that the current user is an admin.
787815 * @param {import('express').Request} request Request object
788816 * @param {import('express').Response} response Response object
@@ -872,4 +900,4 @@ router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.a
872900router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));
873901router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
874902router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
875903router.use('/scripts/extensions/third-party/*', createRouteHandlercreateExtensionsRouteHandler(req => req.user.directories.extensions));