Merge pull request #3145 from SillyTavern/redesign-extension-manager Redesign extension manager
Signed| @@ -12,3 +12,4 @@ access.log | ||
| 12 | 12 | /data |
| 13 | 13 | /cache |
| 14 | 14 | .DS_Store |
| 15 | +/public/scripts/extensions/third-party | |
| @@ -50,3 +50,5 @@ public/css/user.css | ||
| 50 | 50 | /default/scaffold |
| 51 | 51 | public/scripts/extensions/third-party |
| 52 | 52 | /certs |
| 53 | +.aider* | |
| 54 | +.env | |
| @@ -11,3 +11,4 @@ access.log | ||
| 11 | 11 | .github |
| 12 | 12 | .vscode |
| 13 | 13 | .git |
| 14 | +/public/scripts/extensions/third-party | |
| @@ -65,7 +65,7 @@ label[for="extensions_autoconnect"] { | ||
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | .extensions_info .extension_enabled { |
| 68 | 68 | colorfont-weight: greenbold; |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | 71 | .extensions_info .extension_disabled { |
| @@ -76,13 +76,44 @@ label[for="extensions_autoconnect"] { | ||
| 76 | 76 | color: gray; |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | -input.extension_missing[type="checkbox"] { | |
| 79 | +.extensions_info .extension_modules { | |
| 80 | 80 | opacityfont-size: 0.58em; |
| 81 | + font-weight: normal; | |
| 81 | 82 | } |
| 82 | 83 | |
| 83 | 84 | #extensions_list.extensions_info .disabledextension_block { |
| 84 | 85 | text-decorationdisplay: line-throughflex; |
| 85 | 86 | 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; | |
| 86 | 117 | } |
| 87 | 118 | |
| 88 | 119 | .update-button { |
| @@ -105,3 +136,13 @@ input.extension_missing[type="checkbox"] { | ||
| 105 | 136 | #extensionsMenu>div.extension_container:empty { |
| 106 | 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 | +} | |
| @@ -4,29 +4,57 @@ import { eventSource, event_types, saveSettings, saveSettingsDebounced, getReque | ||
| 4 | 4 | import { showLoader } from './loader.js'; |
| 5 | 5 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 6 | 6 | import { renderTemplate, renderTemplateAsync } from './templates.js'; |
| 7 | 7 | import { delay, isSubsetOf, setValueByPath } from './utils.js'; |
| 8 | 8 | import { 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 | + | |
| 9 | 13 | export { |
| 10 | 14 | getContext, |
| 11 | 15 | getApiUrl, |
| 12 | - loadExtensionSettings, | |
| 13 | - runGenerationInterceptors, | |
| 14 | - doExtrasFetch, | |
| 15 | - modules, | |
| 16 | - extension_settings, | |
| 17 | - ModuleWorkerWrapper, | |
| 18 | 16 | }; |
| 19 | 17 | |
| 20 | 18 | /** @type {string[]} */ |
| 21 | 19 | export let extensionNames = []; |
| 22 | 20 | |
| 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 | + */ | |
| 23 | 48 | let manifests = {}; |
| 24 | -const defaultUrl = 'http://localhost:5100'; | |
| 25 | 49 | |
| 26 | -let saveMetadataTimeout = null; | |
| 50 | +/** | |
| 51 | + * Default URL for the Extras API. | |
| 52 | + */ | |
| 53 | +const defaultUrl = 'http://localhost:5100'; | |
| 27 | 54 | |
| 28 | 55 | let requiresReload = false; |
| 29 | 56 | let stateChanged = false; |
| 57 | +let saveMetadataTimeout = null; | |
| 30 | 58 | |
| 31 | 59 | export function saveMetadataDebounced() { |
| 32 | 60 | const context = getContext(); |
| @@ -51,9 +79,9 @@ export function saveMetadataDebounced() { | ||
| 51 | 79 | } |
| 52 | 80 | |
| 53 | 81 | console.debug('Saving metadata...'); |
| 54 | 82 | await newContext.saveMetadata(); |
| 55 | 83 | console.debug('Saved metadata...'); |
| 56 | 84 | }, 1000debounce_timeout.relaxed); |
| 57 | 85 | } |
| 58 | 86 | |
| 59 | 87 | /** |
| @@ -83,7 +111,7 @@ export function renderExtensionTemplateAsync(extensionName, templateId, template | ||
| 83 | 111 | } |
| 84 | 112 | |
| 85 | 113 | // Disables parallel updates |
| 86 | 114 | export class ModuleWorkerWrapper { |
| 87 | 115 | constructor(callback) { |
| 88 | 116 | this.isBusy = false; |
| 89 | 117 | this.callback = callback; |
| @@ -107,7 +135,7 @@ class ModuleWorkerWrapper { | ||
| 107 | 135 | } |
| 108 | 136 | } |
| 109 | 137 | |
| 110 | 138 | export const extension_settings = { |
| 111 | 139 | apiUrl: defaultUrl, |
| 112 | 140 | apiKey: '', |
| 113 | 141 | autoConnect: false, |
| @@ -172,12 +200,6 @@ const extension_settings = { | ||
| 172 | 200 | disabled_attachments: [], |
| 173 | 201 | }; |
| 174 | 202 | |
| 175 | -let modules = []; | |
| 176 | -let activeExtensions = new Set(); | |
| 177 | - | |
| 178 | -const getApiUrl = () => extension_settings.apiUrl; | |
| 179 | -let connectedToApi = false; | |
| 180 | - | |
| 181 | 203 | function showHideExtensionsMenu() { |
| 182 | 204 | // Get the number of menu items that are not hidden |
| 183 | 205 | const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0; |
| @@ -194,7 +216,23 @@ function showHideExtensionsMenu() { | ||
| 194 | 216 | // Periodically check for new extensions |
| 195 | 217 | const menuInterval = setInterval(showHideExtensionsMenu, 1000); |
| 196 | 218 | |
| 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 = {}) { | |
| 198 | 236 | if (!args) { |
| 199 | 237 | args = {}; |
| 200 | 238 | } |
| @@ -213,10 +251,13 @@ async function doExtrasFetch(endpoint, args) { | ||
| 213 | 251 | }); |
| 214 | 252 | } |
| 215 | 253 | |
| 216 | 254 | const response =return await fetch(endpoint, args); |
| 217 | - return response; | |
| 218 | 255 | } |
| 219 | 256 | |
| 257 | +/** | |
| 258 | + * Discovers extensions from the API. | |
| 259 | + * @returns {Promise<{name: string, type: string}[]>} | |
| 260 | + */ | |
| 220 | 261 | async function discoverExtensions() { |
| 221 | 262 | try { |
| 222 | 263 | const response = await fetch('/api/extensions/discover'); |
| @@ -245,6 +286,11 @@ function onEnableExtensionClick() { | ||
| 245 | 286 | enableExtension(name, false); |
| 246 | 287 | } |
| 247 | 288 | |
| 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 | + */ | |
| 248 | 294 | export async function enableExtension(name, reload = true) { |
| 249 | 295 | extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name); |
| 250 | 296 | stateChanged = true; |
| @@ -256,6 +302,11 @@ export async function enableExtension(name, reload = true) { | ||
| 256 | 302 | } |
| 257 | 303 | } |
| 258 | 304 | |
| 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 | + */ | |
| 259 | 310 | export async function disableExtension(name, reload = true) { |
| 260 | 311 | extension_settings.disabledExtensions.push(name); |
| 261 | 312 | stateChanged = true; |
| @@ -267,6 +318,11 @@ export async function disableExtension(name, reload = true) { | ||
| 267 | 318 | } |
| 268 | 319 | } |
| 269 | 320 | |
| 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 | + */ | |
| 270 | 326 | async function getManifests(names) { |
| 271 | 327 | const obj = {}; |
| 272 | 328 | const promises = []; |
| @@ -294,43 +350,36 @@ async function getManifests(names) { | ||
| 294 | 350 | return obj; |
| 295 | 351 | } |
| 296 | 352 | |
| 353 | +/** | |
| 354 | + * Tries to activate all available extensions that are not already active. | |
| 355 | + * @returns {Promise<void>} | |
| 356 | + */ | |
| 297 | 357 | async function activateExtensions() { |
| 298 | 358 | const extensions = Object.entries(manifests).sort((a, b) => sortManifests(a[1].loading_order -, b[1].loading_order)); |
| 299 | 359 | const promises = []; |
| 300 | 360 | |
| 301 | 361 | for (let entry of extensions) { |
| 302 | 362 | const name = entry[0]; |
| 303 | 363 | const manifest = entry[1]; |
| 304 | - const elementExists = document.getElementById(name) !== null; | |
| 305 | 364 | |
| 306 | 365 | if (elementExists || activeExtensions.has(name)) { |
| 307 | 366 | continue; |
| 308 | 367 | } |
| 309 | 368 | |
| 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 { | |
| 313 | 370 | const isDisabled = extension_settings.disabledExtensions.includes(name); |
| 314 | - const li = document.createElement('li'); | |
| 315 | 371 | |
| 316 | 372 | if (meetsModuleRequirements && !isDisabled) { |
| 373 | + try { | |
| 374 | + console.debug('Activating extension', name); | |
| 317 | 375 | const promise = Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]); |
| 318 | 376 | await promise |
| 319 | 377 | .then(() => activeExtensions.add(name)) |
| 320 | 378 | .catch(err => console.log('Could not activate extension: ' +, name, err)); |
| 321 | 379 | promises.push(promise); |
| 322 | 380 | } |
| 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 | - } | |
| 332 | 381 | catch (error) { |
| 333 | 382 | console.error(`'Could not activate extension:', ${name}`); |
| 334 | 383 | console.error(error); |
| 335 | 384 | } |
| 336 | 385 | } |
| @@ -340,8 +389,8 @@ async function activateExtensions() { | ||
| 340 | 389 | } |
| 341 | 390 | |
| 342 | 391 | async function connectClickHandler() { |
| 343 | 392 | const baseUrl = String($('#extensions_url').val()); |
| 344 | 393 | extension_settings.apiUrl = String(baseUrl); |
| 345 | 394 | const testApiKey = $('#extensions_api_key').val(); |
| 346 | 395 | extension_settings.apiKey = String(testApiKey); |
| 347 | 396 | saveSettingsDebounced(); |
| @@ -401,21 +450,11 @@ function notifyUpdatesInputHandler() { | ||
| 401 | 450 | } |
| 402 | 451 | } |
| 403 | 452 | |
| 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 | - | |
| 419 | 458 | async function connectToApi(baseUrl) { |
| 420 | 459 | if (!baseUrl) { |
| 421 | 460 | return; |
| @@ -431,7 +470,7 @@ async function connectToApi(baseUrl) { | ||
| 431 | 470 | const data = await getExtensionsResult.json(); |
| 432 | 471 | modules = data.modules; |
| 433 | 472 | await activateExtensions(); |
| 434 | 473 | await eventSource.emit(event_types.EXTRAS_CONNECTED, modules); |
| 435 | 474 | } |
| 436 | 475 | |
| 437 | 476 | updateStatus(getExtensionsResult.ok); |
| @@ -441,16 +480,29 @@ async function connectToApi(baseUrl) { | ||
| 441 | 480 | } |
| 442 | 481 | } |
| 443 | 482 | |
| 483 | +/** | |
| 484 | + * Updates the status of Extras API connection. | |
| 485 | + * @param {boolean} success Whether the connection was successful | |
| 486 | + */ | |
| 444 | 487 | function updateStatus(success) { |
| 445 | 488 | connectedToApi = success; |
| 446 | 489 | const _text = success ? 't`Connected to API'` : 't`Could not connect to API'`; |
| 447 | 490 | const _class = success ? 'success' : 'failure'; |
| 448 | 491 | $('#extensions_status').text(_text); |
| 449 | 492 | $('#extensions_status').attr('class', _class); |
| 450 | 493 | } |
| 451 | 494 | |
| 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 | + */ | |
| 452 | 501 | function addExtensionStyle(name, manifest) { |
| 453 | 502 | if (!manifest.css) { |
| 503 | + return Promise.resolve(); | |
| 504 | + } | |
| 505 | + | |
| 454 | 506 | return new Promise((resolve, reject) => { |
| 455 | 507 | const url = `/scripts/extensions/${name}/${manifest.css}`; |
| 456 | 508 | |
| @@ -471,11 +523,17 @@ function addExtensionStyle(name, manifest) { | ||
| 471 | 523 | }); |
| 472 | 524 | } |
| 473 | 525 | |
| 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) { | |
| 474 | 534 | return Promise.resolve(); |
| 475 | 535 | } |
| 476 | 536 | |
| 477 | -function addExtensionScript(name, manifest) { | |
| 478 | - if (manifest.js) { | |
| 479 | 537 | return new Promise((resolve, reject) => { |
| 480 | 538 | const url = `/scripts/extensions/${name}/${manifest.js}`; |
| 481 | 539 | let ready = false; |
| @@ -487,11 +545,10 @@ function addExtensionScript(name, manifest) { | ||
| 487 | 545 | script.src = url; |
| 488 | 546 | script.async = true; |
| 489 | 547 | script.onerror = function (err) { |
| 490 | 548 | reject(err, script); |
| 491 | 549 | }; |
| 492 | 550 | 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')) { | |
| 495 | 552 | ready = true; |
| 496 | 553 | resolve(); |
| 497 | 554 | } |
| @@ -501,11 +558,6 @@ function addExtensionScript(name, manifest) { | ||
| 501 | 558 | }); |
| 502 | 559 | } |
| 503 | 560 | |
| 504 | - return Promise.resolve(); | |
| 505 | -} | |
| 506 | - | |
| 507 | - | |
| 508 | - | |
| 509 | 561 | /** |
| 510 | 562 | * Generates HTML string for displaying an extension in the UI. |
| 511 | 563 | * |
| @@ -515,64 +567,85 @@ function addExtensionScript(name, manifest) { | ||
| 515 | 567 | * @param {boolean} isDisabled - Whether the extension is disabled or not. |
| 516 | 568 | * @param {boolean} isExternal - Whether the extension is external or not. |
| 517 | 569 | * @param {string} checkboxClass - The class for the checkbox HTML element. |
| 518 | 570 | * @return {Promise<string>} - The HTML string that represents the extension. |
| 519 | 571 | */ |
| 520 | 572 | async 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(); | |
| 521 | 589 | const displayName = manifest.display_name; |
| 522 | 590 | letconst displayVersion = manifest.version ? ` v${manifest.version}` :|| ''; |
| 523 | - let isUpToDate = true; | |
| 591 | + const externalId = name.replace('third-party', ''); | |
| 524 | - let updateButton = ''; | |
| 525 | 592 | let originHtml = ''; |
| 526 | 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 | } |
| 538 | 596 | |
| 539 | 597 | let toggleElement = isActive || isDisabled ? |
| 540 | 598 | `<input type="checkbox" title="Click to toggle" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` : |
| 541 | 599 | `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`; |
| 542 | 600 | |
| 543 | 601 | 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>`; | |
| 559 | 605 | |
| 560 | 606 | if (isActive && Array.isArray(manifest.optional)) { |
| 561 | 607 | const optional = new Set(manifest.optional); |
| 562 | 608 | modules.forEach(x => optional.delete(x)); |
| 563 | 609 | if (optional.size > 0) { |
| 564 | 610 | const optionalString = DOMPurify.sanitize([...optional].join(', ')); |
| 565 | 611 | extensionHtmlmodulesInfo += `<pdiv class="extension_modules">Optional modules: <span class="optional">${optionalString}</span></pdiv>`; |
| 566 | 612 | } |
| 567 | 613 | } else if (!isDisabled) { // Neither active nor disabled |
| 568 | 614 | const requirements = new Set(manifest.requires); |
| 569 | 615 | modules.forEach(x => requirements.delete(x)); |
| 570 | 616 | if (requirements.size > 0) { |
| 571 | 617 | const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); |
| 572 | 618 | extensionHtmlmodulesInfo += `<pdiv class="extension_modules">Missing modules: <span class="failure">${requirementsString}</span></pdiv>`; |
| 573 | 619 | } |
| 574 | 620 | } |
| 575 | 621 | |
| 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 | 649 | return extensionHtml; |
| 577 | 650 | } |
| 578 | 651 | |
| @@ -580,9 +653,9 @@ async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExt | ||
| 580 | 653 | * Gets extension data and generates the corresponding HTML for displaying the extension. |
| 581 | 654 | * |
| 582 | 655 | * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. |
| 583 | 656 | * @return {Promise<object>} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. |
| 584 | 657 | */ |
| 585 | 658 | async function getExtensionData(extension) { |
| 586 | 659 | const name = extension[0]; |
| 587 | 660 | const manifest = extension[1]; |
| 588 | 661 | const isActive = activeExtensions.has(name); |
| @@ -591,7 +664,7 @@ async function getExtensionData(extension) { | ||
| 591 | 664 | |
| 592 | 665 | const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; |
| 593 | 666 | |
| 594 | 667 | const extensionHtml = await generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); |
| 595 | 668 | |
| 596 | 669 | return { isExternal, extensionHtml }; |
| 597 | 670 | } |
| @@ -614,42 +687,38 @@ function getModuleInformation() { | ||
| 614 | 687 | * Generates the HTML strings for all extensions and displays them in a popup. |
| 615 | 688 | */ |
| 616 | 689 | async function showExtensionsDetails() { |
| 690 | + const abortController = new AbortController(); | |
| 617 | 691 | let popupPromise; |
| 618 | 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 | 703 | <i class="fa-solid fa-spinner fa-spin"></i> |
| 623 | 704 | <span>Loading third-party extensions... Please wait...</span> |
| 624 | 705 | </h3div>`); |
| 625 | 706 | |
| 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); | |
| 629 | 708 | |
| 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 | 710 | |
| 634 | 711 | promisesextensions.forEach(promisevalue => { |
| 635 | - promise.then(value => { | |
| 636 | 712 | const { isExternal, extensionHtml } = value; |
| 637 | 713 | const container = isExternal ? htmlExternal : htmlDefault; |
| 638 | 714 | container.append(extensionHtml); |
| 639 | 715 | }); |
| 640 | - }); | |
| 641 | - | |
| 642 | - Promise.allSettled(promises).then(() => { | |
| 643 | - htmlLoading.remove(); | |
| 644 | - htmlExternal.removeClass('opacity50p'); | |
| 645 | - }); | |
| 646 | 716 | |
| 647 | 717 | const html = $('<div></div>') |
| 648 | 718 | .addClass('extensions_info') |
| 649 | - .append(getModuleInformation()) | |
| 650 | 719 | .append(htmlDefault) |
| 651 | 720 | .append(htmlLoadinghtmlExternal) |
| 652 | 721 | .append(htmlExternalgetModuleInformation()); |
| 653 | 722 | |
| 654 | 723 | /** @type {import('./popup.js').CustomPopupButton} */ |
| 655 | 724 | const updateAllButton = { |
| @@ -662,12 +731,6 @@ async function showExtensionsDetails() { | ||
| 662 | 731 | }, |
| 663 | 732 | }; |
| 664 | 733 | |
| 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 | 734 | let waitingForSave = false; |
| 672 | 735 | |
| 673 | 736 | const popup = new Popup(html, POPUP_TYPE.TEXT, '', { |
| @@ -682,7 +745,7 @@ async function showExtensionsDetails() { | ||
| 682 | 745 | } |
| 683 | 746 | if (stateChanged) { |
| 684 | 747 | waitingForSave = true; |
| 685 | 748 | const toast = toastr.info('t`The page will be reloaded shortly...'`, 't`Extensions state changed'`); |
| 686 | 749 | await saveSettings(); |
| 687 | 750 | toastr.clear(toast); |
| 688 | 751 | waitingForSave = false; |
| @@ -692,12 +755,15 @@ async function showExtensionsDetails() { | ||
| 692 | 755 | }, |
| 693 | 756 | }); |
| 694 | 757 | popupPromise = popup.show(); |
| 758 | + popup.content.scrollTop = initialScrollTop; | |
| 759 | + checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove()); | |
| 695 | 760 | } catch (error) { |
| 696 | 761 | toastr.error('t`Error loading extensions. See browser console for details.'`); |
| 697 | 762 | console.error(error); |
| 698 | 763 | } |
| 699 | 764 | if (popupPromise) { |
| 700 | 765 | await popupPromise; |
| 766 | + abortController.abort(); | |
| 701 | 767 | } |
| 702 | 768 | if (requiresReload) { |
| 703 | 769 | showLoader(); |
| @@ -705,17 +771,26 @@ async function showExtensionsDetails() { | ||
| 705 | 771 | } |
| 706 | 772 | } |
| 707 | 773 | |
| 708 | - | |
| 709 | 774 | /** |
| 710 | 775 | * Handles the click event for the update button of an extension. |
| 711 | 776 | * This function makes a POST request to '/update_extensionapi/extensions/update' with the extension's name. |
| 712 | 777 | * If the extension is already up to date, it displays a success message. |
| 713 | 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 | */ |
| 715 | 780 | async function onUpdateClick() { |
| 781 | + const isCurrentUserAdmin = isAdmin(); | |
| 716 | 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 | 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 | } |
| 720 | 795 | |
| 721 | 796 | /** |
| @@ -728,13 +803,23 @@ async function updateExtension(extensionName, quiet) { | ||
| 728 | 803 | const response = await fetch('/api/extensions/update', { |
| 729 | 804 | method: 'POST', |
| 730 | 805 | headers: getRequestHeaders(), |
| 731 | 806 | body: JSON.stringify({ extensionName }), |
| 807 | + extensionName, | |
| 808 | + global: getExtensionType(extensionName) === 'global', | |
| 809 | + }), | |
| 732 | 810 | }); |
| 733 | 811 | |
| 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 | 819 | const data = await response.json(); |
| 735 | 820 | |
| 736 | 821 | if (!quiet) { |
| 737 | 822 | void showExtensionsDetails(); |
| 738 | 823 | } |
| 739 | 824 | |
| 740 | 825 | if (data.isUpToDate) { |
| @@ -757,44 +842,122 @@ async function updateExtension(extensionName, quiet) { | ||
| 757 | 842 | */ |
| 758 | 843 | async function onDeleteClick() { |
| 759 | 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 | 852 | // use callPopup to create a popup for the user to confirm before delete |
| 761 | 853 | const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {}); |
| 762 | 854 | if (confirmation === POPUP_RESULT.AFFIRMATIVE) { |
| 763 | 855 | await deleteExtension(extensionName); |
| 764 | 856 | } |
| 765 | 857 | } |
| 766 | 858 | |
| 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 | + */ | |
| 767 | 924 | export async function deleteExtension(extensionName) { |
| 768 | 925 | try { |
| 769 | 926 | await fetch('/api/extensions/delete', { |
| 770 | 927 | method: 'POST', |
| 771 | 928 | headers: getRequestHeaders(), |
| 772 | 929 | body: JSON.stringify({ extensionName }), |
| 930 | + extensionName, | |
| 931 | + global: getExtensionType(extensionName) === 'global', | |
| 932 | + }), | |
| 773 | 933 | }); |
| 774 | 934 | } catch (error) { |
| 775 | 935 | console.error('Error:', error); |
| 776 | 936 | } |
| 777 | 937 | |
| 778 | 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 | } |
| 783 | 941 | |
| 784 | 942 | /** |
| 785 | 943 | * Fetches the version details of a specific extension. |
| 786 | 944 | * |
| 787 | 945 | * @param {string} extensionName - The name of the extension. |
| 946 | + * @param {AbortSignal} [abortSignal] - The signal to abort the operation. | |
| 788 | 947 | * @return {Promise<object>} - An object containing the extension's version details. |
| 789 | 948 | * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl. |
| 790 | 949 | * @throws {error} - If there is an error during the fetch operation, it logs the error to the console. |
| 791 | 950 | */ |
| 792 | 951 | async function getExtensionVersion(extensionName, abortSignal) { |
| 793 | 952 | try { |
| 794 | 953 | const response = await fetch('/api/extensions/version', { |
| 795 | 954 | method: 'POST', |
| 796 | 955 | headers: getRequestHeaders(), |
| 797 | 956 | body: JSON.stringify({ extensionName }), |
| 957 | + extensionName, | |
| 958 | + global: getExtensionType(extensionName) === 'global', | |
| 959 | + }), | |
| 960 | + signal: abortSignal, | |
| 798 | 961 | }); |
| 799 | 962 | |
| 800 | 963 | const data = await response.json(); |
| @@ -807,22 +970,26 @@ async function getExtensionVersion(extensionName) { | ||
| 807 | 970 | /** |
| 808 | 971 | * Installs a third-party extension via the API. |
| 809 | 972 | * @param {string} url Extension repository URL |
| 973 | + * @param {boolean} global Is the extension global? | |
| 810 | 974 | * @returns {Promise<void>} |
| 811 | 975 | */ |
| 812 | 976 | export async function installExtension(url, global) { |
| 813 | 977 | console.debug('Extension installation started', url); |
| 814 | 978 | |
| 815 | 979 | toastr.info('t`Please wait...'`, 't`Installing extension'`); |
| 816 | 980 | |
| 817 | 981 | const request = await fetch('/api/extensions/install', { |
| 818 | 982 | method: 'POST', |
| 819 | 983 | headers: getRequestHeaders(), |
| 820 | 984 | body: JSON.stringify({ url }), |
| 985 | + url, | |
| 986 | + global, | |
| 987 | + }), | |
| 821 | 988 | }); |
| 822 | 989 | |
| 823 | 990 | if (!request.ok) { |
| 824 | 991 | const text = await request.text(); |
| 825 | 992 | toastr.warning(text || request.statusText, 't`Extension installation failed'`, { timeOut: 5000 }); |
| 826 | 993 | console.error('Extension installation failed', request.status, request.statusText, text); |
| 827 | 994 | return; |
| 828 | 995 | } |
| @@ -840,7 +1007,7 @@ export async function installExtension(url) { | ||
| 840 | 1007 | * @param {boolean} versionChanged Is this a version change? |
| 841 | 1008 | * @param {boolean} enableAutoUpdate Enable auto-update |
| 842 | 1009 | */ |
| 843 | 1010 | export async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) { |
| 844 | 1011 | if (settings.extension_settings) { |
| 845 | 1012 | Object.assign(extension_settings, settings.extension_settings); |
| 846 | 1013 | } |
| @@ -852,7 +1019,9 @@ async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) | ||
| 852 | 1019 | |
| 853 | 1020 | // Activate offline extensions |
| 854 | 1021 | await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD); |
| 855 | 1022 | extensionNamesconst extensions = await discoverExtensions(); |
| 1023 | + extensionNames = extensions.map(x => x.name); | |
| 1024 | + extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type])); | |
| 856 | 1025 | manifests = await getManifests(extensionNames); |
| 857 | 1026 | |
| 858 | 1027 | if (versionChanged && enableAutoUpdate) { |
| @@ -873,6 +1042,86 @@ export function doDailyExtensionUpdatesCheck() { | ||
| 873 | 1042 | }, 1); |
| 874 | 1043 | } |
| 875 | 1044 | |
| 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 | + | |
| 876 | 1125 | /** |
| 877 | 1126 | * Checks if there are updates available for 3rd-party extensions. |
| 878 | 1127 | * @param {boolean} force Skip nag check |
| @@ -891,21 +1140,26 @@ async function checkForExtensionUpdates(force) { | ||
| 891 | 1140 | localStorage.setItem(STORAGE_NAG_KEY, currentDate); |
| 892 | 1141 | } |
| 893 | 1142 | |
| 1143 | + const isCurrentUserAdmin = isAdmin(); | |
| 894 | 1144 | const updatesAvailable = []; |
| 895 | 1145 | const promises = []; |
| 896 | 1146 | |
| 897 | 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 | 1154 | if (manifest.auto_update && id.startsWith('third-party')) { |
| 899 | 1155 | const promise = new PromiseenqueueVersionCheck(async (resolve, reject) => { |
| 900 | 1156 | try { |
| 901 | 1157 | const data = await getExtensionVersion(id.replace('third-party', '')); |
| 902 | 1158 | if (!data.isUpToDate === false) { |
| 903 | 1159 | updatesAvailable.push(manifest.display_name); |
| 904 | 1160 | } |
| 905 | - resolve(); | |
| 906 | 1161 | } catch (error) { |
| 907 | 1162 | console.error('Error checking for extension updates', error); |
| 908 | - reject(); | |
| 909 | 1163 | } |
| 910 | 1164 | }); |
| 911 | 1165 | promises.push(promise); |
| @@ -930,8 +1184,14 @@ async function autoUpdateExtensions(forceAll) { | ||
| 930 | 1184 | } |
| 931 | 1185 | |
| 932 | 1186 | const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 }); |
| 1187 | + const isCurrentUserAdmin = isAdmin(); | |
| 933 | 1188 | const promises = []; |
| 934 | 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 | 1195 | if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) { |
| 936 | 1196 | console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`); |
| 937 | 1197 | promises.push(updateExtension(id.replace('third-party', ''), true)); |
| @@ -947,7 +1207,7 @@ async function autoUpdateExtensions(forceAll) { | ||
| 947 | 1207 | * @param {number} contextSize Context size |
| 948 | 1208 | * @returns {Promise<boolean>} True if generation should be aborted |
| 949 | 1209 | */ |
| 950 | 1210 | export async function runGenerationInterceptors(chat, contextSize) { |
| 951 | 1211 | let aborted = false; |
| 952 | 1212 | let exitImmediately = false; |
| 953 | 1213 | |
| @@ -956,11 +1216,11 @@ async function runGenerationInterceptors(chat, contextSize) { | ||
| 956 | 1216 | exitImmediately = immediately; |
| 957 | 1217 | }; |
| 958 | 1218 | |
| 959 | 1219 | for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifests(a.loading_order -, b.loading_order))) { |
| 960 | 1220 | const interceptorKey = manifest.generate_interceptor; |
| 961 | 1221 | if (typeof windowglobalThis[interceptorKey] === 'function') { |
| 962 | 1222 | try { |
| 963 | 1223 | await windowglobalThis[interceptorKey](chat, contextSize, abort); |
| 964 | 1224 | } catch (e) { |
| 965 | 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 | 1293 | * @returns {Promise<void>} |
| 1034 | 1294 | */ |
| 1035 | 1295 | export async function openThirdPartyExtensionMenu(suggestUrl = '') { |
| 1036 | 1296 | const htmlisCurrentUserAdmin = await renderTemplateAsyncisAdmin('installExtension'); |
| 1037 | 1297 | 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(); | |
| 1038 | 1313 | |
| 1039 | 1314 | if (!input) { |
| 1040 | 1315 | console.debug('Extension install cancelled'); |
| @@ -1042,11 +1317,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') { | ||
| 1042 | 1317 | } |
| 1043 | 1318 | |
| 1044 | 1319 | const url = String(input).trim(); |
| 1045 | 1320 | await installExtension(url, global); |
| 1046 | 1321 | } |
| 1047 | 1322 | |
| 1048 | - | |
| 1049 | - | |
| 1050 | 1323 | export async function initExtensions() { |
| 1051 | 1324 | await addExtensionsButtonAndMenu(); |
| 1052 | 1325 | $('#extensionsMenuButton').css('display', 'flex'); |
| @@ -1055,10 +1328,11 @@ export async function initExtensions() { | ||
| 1055 | 1328 | $('#extensions_autoconnect').on('input', autoConnectInputHandler); |
| 1056 | 1329 | $('#extensions_details').on('click', showExtensionsDetails); |
| 1057 | 1330 | $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler); |
| 1058 | 1331 | $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick); |
| 1059 | 1332 | $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); |
| 1060 | 1333 | $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); |
| 1061 | 1334 | $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); |
| 1335 | + $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); | |
| 1062 | 1336 | |
| 1063 | 1337 | /** |
| 1064 | 1338 | * Handles the click event for the third-party extension import button. |
| @@ -31,7 +31,7 @@ export async function setUserControls(isEnabled) { | ||
| 31 | 31 | * Check if the current user is an admin. |
| 32 | 32 | * @returns {boolean} True if the current user is an admin |
| 33 | 33 | */ |
| 34 | 34 | export function isAdmin() { |
| 35 | 35 | if (!currentUser) { |
| 36 | 36 | return false; |
| 37 | 37 | } |
| @@ -3,6 +3,7 @@ export const PUBLIC_DIRECTORIES = { | ||
| 3 | 3 | backups: 'backups/', |
| 4 | 4 | sounds: 'public/sounds', |
| 5 | 5 | extensions: 'public/scripts/extensions', |
| 6 | + globalExtensions: 'public/scripts/extensions/third-party', | |
| 6 | 7 | }; |
| 7 | 8 | |
| 8 | 9 | export const SETTINGS_FILE = 'settings.json'; |
| @@ -73,8 +73,19 @@ router.post('/install', jsonParser, async (request, response) => { | ||
| 73 | 73 | fs.mkdirSync(path.join(request.user.directories.extensions)); |
| 74 | 74 | } |
| 75 | 75 | |
| 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'))); | |
| 78 | 89 | |
| 79 | 90 | if (fs.existsSync(extensionPath)) { |
| 80 | 91 | return response.status(409).send(`Directory already exists at ${extensionPath}`); |
| @@ -83,10 +94,8 @@ router.post('/install', jsonParser, async (request, response) => { | ||
| 83 | 94 | await git.clone(url, extensionPath, { '--depth': 1 }); |
| 84 | 95 | console.log(`Extension has been cloned at ${extensionPath}`); |
| 85 | 96 | |
| 86 | - | |
| 87 | 97 | const { version, author, display_name } = await getManifest(extensionPath); |
| 88 | 98 | |
| 89 | - | |
| 90 | 99 | return response.send({ version, author, display_name, extensionPath }); |
| 91 | 100 | } catch (error) { |
| 92 | 101 | console.log('Importing custom content failed', error); |
| @@ -112,8 +121,15 @@ router.post('/update', jsonParser, async (request, response) => { | ||
| 112 | 121 | } |
| 113 | 122 | |
| 114 | 123 | try { |
| 115 | 124 | 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); | |
| 117 | 133 | |
| 118 | 134 | if (!fs.existsSync(extensionPath)) { |
| 119 | 135 | return response.status(404).send(`Directory does not exist at ${extensionPath}`); |
| @@ -122,7 +138,6 @@ router.post('/update', jsonParser, async (request, response) => { | ||
| 122 | 138 | const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); |
| 123 | 139 | const currentBranch = await git.cwd(extensionPath).branch(); |
| 124 | 140 | if (!isUpToDate) { |
| 125 | - | |
| 126 | 141 | await git.cwd(extensionPath).pull('origin', currentBranch.current); |
| 127 | 142 | console.log(`Extension has been updated at ${extensionPath}`); |
| 128 | 143 | } else { |
| @@ -140,6 +155,50 @@ router.post('/update', jsonParser, async (request, response) => { | ||
| 140 | 155 | } |
| 141 | 156 | }); |
| 142 | 157 | |
| 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 | + | |
| 143 | 202 | /** |
| 144 | 203 | * HTTP POST handler function to get the current git commit hash and branch name for a given extension. |
| 145 | 204 | * 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 | } |
| 158 | 217 | |
| 159 | 218 | try { |
| 160 | 219 | const { extensionName, global } = request.body.extensionName; |
| 161 | 220 | const extensionPathbasePath = pathglobal ? PUBLIC_DIRECTORIES.join(globalExtensions : request.user.directories.extensions, extensionName); |
| 221 | + const extensionPath = path.join(basePath, sanitize(extensionName)); | |
| 162 | 222 | |
| 163 | 223 | if (!fs.existsSync(extensionPath)) { |
| 164 | 224 | return response.status(404).send(`Directory does not exist at ${extensionPath}`); |
| 165 | 225 | } |
| 166 | 226 | |
| 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 | 236 | const currentBranch = await git.cwd(extensionPath).branch(); |
| 168 | 237 | // get only the working branch |
| 169 | 238 | const currentBranchName = currentBranch.current; |
| 170 | 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 | 241 | const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); |
| 174 | 242 | |
| 175 | 243 | return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl }); |
| @@ -193,11 +261,16 @@ router.post('/delete', jsonParser, async (request, response) => { | ||
| 193 | 261 | return response.status(400).send('Bad Request: extensionName is required in the request body.'); |
| 194 | 262 | } |
| 195 | 263 | |
| 196 | - // Sanitize the extension name to prevent directory traversal | |
| 197 | - const extensionName = sanitize(request.body.extensionName); | |
| 198 | - | |
| 199 | 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)); | |
| 201 | 274 | |
| 202 | 275 | if (!fs.existsSync(extensionPath)) { |
| 203 | 276 | return response.status(404).send(`Directory does not exist at ${extensionPath}`); |
| @@ -219,26 +292,38 @@ router.post('/delete', jsonParser, async (request, response) => { | ||
| 219 | 292 | * If the folder is called third-party, search for subfolders instead |
| 220 | 293 | */ |
| 221 | 294 | router.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 | 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 | } |
| 233 | 298 | |
| 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); | |
| 241 | 302 | |
| 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 })); | |
| 242 | 309 | |
| 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 | }); |
| @@ -783,6 +783,34 @@ function createRouteHandler(directoryFn) { | ||
| 783 | 783 | } |
| 784 | 784 | |
| 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 | + */ | |
| 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 | +/** | |
| 786 | 814 | * Verifies that the current user is an admin. |
| 787 | 815 | * @param {import('express').Request} request Request object |
| 788 | 816 | * @param {import('express').Response} response Response object |
| @@ -872,4 +900,4 @@ router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.a | ||
| 872 | 900 | router.use('/assets/*', createRouteHandler(req => req.user.directories.assets)); |
| 873 | 901 | router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages)); |
| 874 | 902 | router.use('/user/files/*', createRouteHandler(req => req.user.directories.files)); |
| 875 | 903 | router.use('/scripts/extensions/third-party/*', createRouteHandlercreateExtensionsRouteHandler(req => req.user.directories.extensions)); |