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