Extension management improvements (#5552) * feat: enhance asset management with extension categories Co-authored-by: Copilot <copilot@github.com> * fix: enhance extension name validation in server endpoints * feat: display extension author in the extensions list * fix: unify server error response format Co-authored-by: Copilot <copilot@github.com> * feat: add splash on installing third-party for the first time * fix: add URL format validation, unify validation error messages Co-authored-by: Copilot <copilot@github.com> * fix: apply object freeze to EMPTY_AUTHOR value Co-authored-by: Copilot <copilot@github.com> * fix: typecheck extensionName in API requests Co-authored-by: Copilot <copilot@github.com> * feat: add feature flag guard to extensions endpoints Co-authored-by: Copilot <copilot@github.com> * fix: parse URL before checking Co-authored-by: Copilot <copilot@github.com> * fix: use case insensitive regex check * fix: make debug log more useful Co-authored-by: Copilot <copilot@github.com> * fix: add pre-validation of URL format and protocol Co-authored-by: Copilot <copilot@github.com> * fix: leaner installation success toast * fix: settings data loss when extensions are disabled * fix: don't try to auto-focus elements that don't exist Co-authored-by: Copilot <copilot@github.com> * fix: set Popup.defaultResult to negative Co-authored-by: Copilot <copilot@github.com> * revert: restore undefined default result --------- Co-authored-by: Copilot <copilot@github.com>

5512473b294f60982bc5ef6252977cadec590690

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

Signed
9 files changed, +304 -96Ignore whitespace
public/css/extensions-panel.css+12 -2
@@ -97,13 +97,23 @@ label[for="extensions_autoconnect"] {
97 font-size: 1.05em;97 font-size: 1.05em;
98}98}
9999
100.extensions_info .extension_version {100.extensions_info :is(.extension_version, .extension_author) {
101 opacity: 0.8;101 opacity: 0.8;
102 font-size: 0.8em;102 font-size: 0.8em;
103 font-weight: normal;103 font-weight: normal;
104 margin-left: 2px;104 margin-left: 2px;
105}105}
106106
107.extensions_info :is(.extension_version, .extension_author):empty {
108 display: none;
109}
110
111.extensions_info .extension_author {
112 display: inline-flex;
113 gap: 2px;
114 align-items: baseline;
115}
116
107.extensions_info .extension_block a {117.extensions_info .extension_block a {
108 color: var(--SmartThemeBodyColor);118 color: var(--SmartThemeBodyColor);
109}119}
@@ -161,4 +171,4 @@ input.extension_missing[type="checkbox"] {
161 z-index: 1;171 z-index: 1;
162 margin-bottom: 10px;172 margin-bottom: 10px;
163 padding: 5px;173 padding: 5px;
164}
164 \ No newline at end of file \ No newline at end of file
174}
public/script.js+4 -0
@@ -7953,6 +7953,10 @@ export async function getSettings(initLoaderHandle = null) {
7953 const isVersionChanged = settings.currentVersion !== currentVersion;7953 const isVersionChanged = settings.currentVersion !== currentVersion;
7954 await loadExtensionSettings(settings, isVersionChanged, enableAutoUpdate);7954 await loadExtensionSettings(settings, isVersionChanged, enableAutoUpdate);
7955 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED);7955 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED);
7956 } else {
7957 Object.assign(extension_settings, (settings.extension_settings ?? {}));
7958 $('#third_party_extension_button').addClass('disabled');
7959 $('#extensions_details').addClass('disabled');
7956 }7960 }
79577961
7958 firstRun = !!settings.firstRun;7962 firstRun = !!settings.firstRun;
public/scripts/extensions.js+108 -3
@@ -61,6 +61,19 @@ let manifests = {};
61 */61 */
62const defaultUrl = 'http://localhost:5100';62const defaultUrl = 'http://localhost:5100';
6363
64/**
65 * Checks if the extension is officially supported by its URL pattern.
66 * @param {string} url URL to check
67 * @returns {boolean} True if the URL matches the pattern, false otherwise (or not a valid URL)
68 */
69export const isOfficialExtension = (url) => {
70 try {
71 return /^https:\/\/github\.com\/SillyTavern\/(.+)$/i.test(new URL(url).href);
72 } catch (e) {
73 return false;
74 }
75};
76
64let requiresReload = false;77let requiresReload = false;
65let stateChanged = false;78let stateChanged = false;
66let saveMetadataTimeout = null;79let saveMetadataTimeout = null;
@@ -928,6 +941,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
928 ${originHtml}941 ${originHtml}
929 <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">942 <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
930 <span class="extension_name">${DOMPurify.sanitize(displayName)}</span>943 <span class="extension_name">${DOMPurify.sanitize(displayName)}</span>
944 <span class="extension_author"></span>
931 <span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>945 <span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>
932 ${modulesInfo}946 ${modulesInfo}
933 </span>947 </span>
@@ -1557,9 +1571,53 @@ async function switchExtensionBranch(extensionName, isGlobal, branch) {
1557 * Installs a third-party extension via the API.1571 * Installs a third-party extension via the API.
1558 * @param {string} url Extension repository URL1572 * @param {string} url Extension repository URL
1559 * @param {boolean} global Is the extension global?1573 * @param {boolean} global Is the extension global?
1560 * @returns {Promise<void>}1574 * @param {string} [branch] Optional branch to install, if not provided the default branch will be used
1575 * @returns {Promise<boolean>} True if the extension was installed successfully, false otherwise
1561 */1576 */
1562export async function installExtension(url, global, branch = '') {1577export async function installExtension(url, global, branch = '') {
1578 try {
1579 const parsedUrl = new URL(url);
1580 if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
1581 throw new Error('Invalid URL protocol');
1582 }
1583
1584 // Normalize the URL (resolve relative paths, remove redundant segments, etc.)
1585 url = parsedUrl.href;
1586 } catch (error) {
1587 console.error('Invalid URL:', error);
1588 toastr.error(t`Only valid HTTP and HTTPS URLs are allowed.`, t`Invalid URL`);
1589 return false;
1590 }
1591
1592 if (!isOfficialExtension(url)) {
1593 const extensionInstallationWarningKey = 'extensionInstallationWarningShown';
1594 if (accountStorage.getItem(extensionInstallationWarningKey)) {
1595 console.debug('Bypassed URL check for third-party extension (account preference).', url);
1596 } else {
1597 let dismissWarning = false;
1598 const confirmation = await Popup.show.confirm(
1599 t`Install a third-party extension?`,
1600 await renderTemplateAsync('thirdPartyExtensionWarning'),
1601 {
1602 customInputs: [{ id: 'dontAskAgain', type: 'checkbox', label: t`Don't show this warning again`, defaultState: false }],
1603 onClose: (popup) => {
1604 if (!popup.result) {
1605 return;
1606 }
1607 dismissWarning = Boolean(popup.inputResults?.get('dontAskAgain') ?? false);
1608 },
1609 okButton: t`Yes, install it`,
1610 cancelButton: t`No, cancel`,
1611 });
1612 if (!confirmation) {
1613 return false;
1614 }
1615 if (dismissWarning) {
1616 accountStorage.setItem(extensionInstallationWarningKey, '1');
1617 }
1618 }
1619 }
1620
1563 console.debug('Extension installation started', url);1621 console.debug('Extension installation started', url);
15641622
1565 toastr.info(t`Please wait...`, t`Installing extension`);1623 toastr.info(t`Please wait...`, t`Installing extension`);
@@ -1578,11 +1636,11 @@ export async function installExtension(url, global, branch = '') {
1578 const text = await request.text();1636 const text = await request.text();
1579 toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 });1637 toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 });
1580 console.error('Extension installation failed', request.status, request.statusText, text);1638 console.error('Extension installation failed', request.status, request.statusText, text);
1581 return;1639 return false;
1582 }1640 }
15831641
1584 const response = await request.json();1642 const response = await request.json();
1585 toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`);1643 toastr.success(t`Extension '${response.display_name}' has been installed successfully!`, t`Extension installation successful`);
1586 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);1644 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);
1587 await loadExtensionSettings({}, false, false);1645 await loadExtensionSettings({}, false, false);
1588 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response);1646 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response);
@@ -1591,6 +1649,8 @@ export async function installExtension(url, global, branch = '') {
1591 const extensionName = `third-party/${response.folderName}`;1649 const extensionName = `third-party/${response.folderName}`;
1592 await callExtensionHook(extensionName, 'install');1650 await callExtensionHook(extensionName, 'install');
1593 }1651 }
1652
1653 return true;
1594}1654}
15951655
1596/**1656/**
@@ -1701,6 +1761,18 @@ async function checkForUpdatesManual(sortFn, abortSignal) {
1701 }1761 }
1702 }1762 }
17031763
1764 const authorElement = extensionBlock.querySelector('.extension_author');
1765 if (authorElement) {
1766 const author = getAuthorFromUrl(origin) || EMPTY_AUTHOR;
1767 if (author.name) {
1768 const icon = document.createElement('i');
1769 icon.classList.add('fa-solid', 'fa-at', 'fa-xs');
1770 const name = document.createElement('span');
1771 name.textContent = author.name;
1772 authorElement.append(icon, name);
1773 }
1774 }
1775
1704 const versionElement = extensionBlock.querySelector('.extension_version');1776 const versionElement = extensionBlock.querySelector('.extension_version');
1705 if (versionElement) {1777 if (versionElement) {
1706 versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`;1778 versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`;
@@ -2057,6 +2129,39 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
2057 await installExtension(url, global, branchName);2129 await installExtension(url, global, branchName);
2058}2130}
20592131
2132/**
2133 * Sentinel value representing an empty author, used when author information cannot be extracted from a URL.
2134 * @type {{name: string, url: string}}
2135 */
2136export const EMPTY_AUTHOR = Object.freeze({
2137 name: '',
2138 url: '',
2139});
2140
2141/**
2142 * Extracts the repository author from a given URL.
2143 * @param {string} url - The URL of the repository.
2144 * @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found.
2145 */
2146export function getAuthorFromUrl(url) {
2147 const result = structuredClone(EMPTY_AUTHOR);
2148
2149 try {
2150 const parsedUrl = new URL(url);
2151 const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0);
2152
2153 // TODO: Handle non-GitHub URLs if needed
2154 if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) {
2155 result.name = pathSegments[0];
2156 result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`;
2157 }
2158 } catch (error) {
2159 console.debug('Error parsing URL:', error);
2160 }
2161
2162 return result;
2163}
2164
2060export async function initExtensions() {2165export async function initExtensions() {
2061 await addExtensionsButtonAndMenu();2166 await addExtensionsButtonAndMenu();
2062 $('#extensionsMenuButton').css('display', 'flex');2167 $('#extensionsMenuButton').css('display', 'flex');
public/scripts/extensions/assets/index.js+30 -38
@@ -5,7 +5,7 @@ TODO:
55
6import { DOMPurify } from '../../../lib.js';6import { DOMPurify } from '../../../lib.js';
7import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';7import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
8import { deleteExtension, extensionNames, getContext, installExtension, renderExtensionTemplateAsync } from '../../extensions.js';8import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';
9import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';9import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
10import { executeSlashCommandsWithOptions } from '../../slash-commands.js';10import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
11import { accountStorage } from '../../util/AccountStorage.js';11import { accountStorage } from '../../util/AccountStorage.js';
@@ -60,35 +60,6 @@ const KNOWN_TYPES = {
60 'blip': t`Blip sounds`,60 'blip': t`Blip sounds`,
61};61};
6262
63const EMPTY_AUTHOR = {
64 name: '',
65 url: '',
66};
67
68/**
69 * Extracts the repository author from a given URL.
70 * @param {string} url - The URL of the repository.
71 * @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found.
72 */
73function getAuthorFromUrl(url) {
74 const result = structuredClone(EMPTY_AUTHOR);
75
76 try {
77 const parsedUrl = new URL(url);
78 const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0);
79
80 // TODO: Handle non-GitHub URLs if needed
81 if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) {
82 result.name = pathSegments[0];
83 result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`;
84 }
85 } catch (error) {
86 console.debug(DEBUG_PREFIX, 'Error parsing URL:', error);
87 }
88
89 return result;
90}
91
92async function downloadAssetsList(url) {63async function downloadAssetsList(url) {
93 updateCurrentAssets().then(async function () {64 updateCurrentAssets().then(async function () {
94 fetch(url, { cache: 'no-cache' })65 fetch(url, { cache: 'no-cache' })
@@ -153,7 +124,15 @@ async function downloadAssetsList(url) {
153 element.off('click');124 element.off('click');
154 label.removeClass('fa-download');125 label.removeClass('fa-download');
155 this.classList.add('asset-download-button-loading');126 this.classList.add('asset-download-button-loading');
156 await installAsset(asset.url, assetType, asset.id);127 const result = await installAsset(asset.url, assetType, asset.id);
128 if (!result) {
129 this.classList.remove('asset-download-button-loading');
130 label.addClass('fa-download');
131 label.removeClass('fa-spinner');
132 label.removeClass('fa-spin');
133 element.on('click', assetInstall);
134 return;
135 }
157 label.addClass('fa-check');136 label.addClass('fa-check');
158 this.classList.remove('asset-download-button-loading');137 this.classList.remove('asset-download-button-loading');
159 element.on('click', assetDelete);138 element.on('click', assetDelete);
@@ -248,7 +227,14 @@ async function downloadAssetsList(url) {
248227
249 assetBlock.addClass('asset-block');228 assetBlock.addClass('asset-block');
250229
251 assetTypeMenu.append(assetBlock);230 if (assetType === 'extension') {
231 const extensionBlockList = isOfficialExtension(asset.url)
232 ? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions')
233 : assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions');
234 extensionBlockList.append(assetBlock);
235 } else {
236 assetTypeMenu.append(assetBlock);
237 }
252 }238 }
253 assetTypeMenu.appendTo('#assets_menu');239 assetTypeMenu.appendTo('#assets_menu');
254 assetTypeMenu.on('click', 'a.asset_preview', previewAsset);240 assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
@@ -322,9 +308,9 @@ async function installAsset(url, assetType, filename) {
322 try {308 try {
323 if (category === 'extension') {309 if (category === 'extension') {
324 console.debug(DEBUG_PREFIX, 'Installing extension ', url);310 console.debug(DEBUG_PREFIX, 'Installing extension ', url);
325 await installExtension(url, false);311 const result = await installExtension(url, false);
326 console.debug(DEBUG_PREFIX, 'Extension installed.');312 console.debug(DEBUG_PREFIX, 'Extension installed.');
327 return;313 return result;
328 }314 }
329315
330 const body = { url, category, filename };316 const body = { url, category, filename };
@@ -343,10 +329,12 @@ async function installAsset(url, assetType, filename) {
343 await processDroppedFiles([file]);329 await processDroppedFiles([file]);
344 console.debug(DEBUG_PREFIX, 'Character downloaded.');330 console.debug(DEBUG_PREFIX, 'Character downloaded.');
345 }331 }
332 return true;
346 }333 }
334 return false;
347 } catch (err) {335 } catch (err) {
348 console.log(err);336 console.log(err);
349 return [];337 return false;
350 }338 }
351}339}
352340
@@ -398,9 +386,13 @@ async function openCharacterBrowser(forceDefault) {
398386
399 downloadButton.toggle(!isInstalled).on('click', async () => {387 downloadButton.toggle(!isInstalled).on('click', async () => {
400 downloadButton.toggleClass('fa-download fa-spinner fa-spin');388 downloadButton.toggleClass('fa-download fa-spinner fa-spin');
401 await installAsset(character.url, 'character', character.id);389 const result = await installAsset(character.url, 'character', character.id);
402 downloadButton.hide();390 if (result) {
403 checkMark.show();391 downloadButton.hide();
392 checkMark.show();
393 } else {
394 downloadButton.toggleClass('fa-download fa-spinner fa-spin');
395 }
404 });396 });
405397
406 checkMark.toggle(isInstalled);398 checkMark.toggle(isInstalled);
public/scripts/extensions/assets/installation.html+19 -1
@@ -1,4 +1,22 @@
1<div class="assets-list-git">1<div class="assets-list-git">
2 <span data-i18n="extension_install_1">To download extensions from this page, you need to have </span><a href="https://git-scm.com/downloads" target="_blank">Git</a><span data-i18n="extension_install_2"> installed.</span><br>2 <span data-i18n="extension_install_1">To download extensions from this page, you need to have </span><a href="https://git-scm.com/downloads" target="_blank">Git</a><span data-i18n="extension_install_2"> installed.</span><br>
3 <span data-i18n="extension_install_3">Click the </span><i class="fa-solid fa-sm fa-arrow-up-right-from-square"></i><span data-i18n="extension_install_4"> icon to visit the Extension's repo for tips on how to use it.</span>3 <span data-i18n="extension_install_3">Click the </span><i class="fa-solid fa-sm fa-arrow-up-right-from-square"></i><span data-i18n="extension_install_4"> icon to visit the Extension's repo for tips on how to use it.</span>
4</div>
4 \ No newline at end of file \ No newline at end of file
4</div>
5<div class="assets-list-extensions-official">
6 <h2 data-i18n="Official Extensions">Official Extensions</h2>
7 <div class="info-block hint">
8 <small class="assets-list-description" data-i18n="These extensions are maintained by the SillyTavern team.">
9 These extensions are maintained by the SillyTavern team.
10 </small>
11 </div>
12 <div class="assets-list-extensions"></div>
13</div>
14<div class="assets-list-extensions-community">
15 <h2 data-i18n="Community Extensions">Community Extensions</h2>
16 <div class="info-block warning">
17 <small data-i18n="Community extensions are not reviewed or verified by the SillyTavern team. Please exercise caution when installing.">
18 Community extensions are not reviewed or verified by the SillyTavern team. Please exercise caution when installing.
19 </small>
20 </div>
21 <div class="assets-list-extensions"></div>
22</div>
public/scripts/extensions/assets/style.css+16 -3
@@ -27,15 +27,20 @@
27 margin-bottom: 0.25em;27 margin-bottom: 0.25em;
28}28}
2929
30.assets-list-div h2 {
31 margin: 0;
32 font-size: 1.1em;
33}
34
30.assets-list-div h3 {35.assets-list-div h3 {
31 text-transform: capitalize;36 text-transform: capitalize;
32}37}
3338
34.assets-list-div i a {39.assets-list-div .asset-block a {
35 color: inherit;40 color: inherit;
36}41}
3742
38.assets-list-div>i {43.assets-list-div .asset-block {
39 display: flex;44 display: flex;
40 flex-direction: row;45 flex-direction: row;
41 align-items: center;46 align-items: center;
@@ -46,7 +51,7 @@
46 border-bottom: 1px solid var(--SmartThemeBorderColor);51 border-bottom: 1px solid var(--SmartThemeBorderColor);
47}52}
4853
49.assets-list-div i span:first-of-type {54.assets-list-div .asset-block span:first-of-type {
50 font-weight: bold;55 font-weight: bold;
51}56}
5257
@@ -198,3 +203,11 @@
198.asset-name>b {203.asset-name>b {
199 font-weight: 600;204 font-weight: 600;
200}205}
206
207div:is(.assets-list-extensions-official, .assets-list-extensions-community):has(.assets-list-extensions:empty) {
208 display: none;
209}
210
211div:is(.assets-list-extensions-official, .assets-list-extensions-community) {
212 margin-top: 10px;
213}
public/scripts/popup.js+4 -0
@@ -718,6 +718,10 @@ export class Popup {
718 }718 }
719 }719 }
720720
721 if (!control) {
722 return;
723 }
724
721 if (applyAutoFocus) {725 if (applyAutoFocus) {
722 control.setAttribute('autofocus', '');726 control.setAttribute('autofocus', '');
723 // Manually enable tabindex too, as this might only be applied by the interactable functionality in the background, but too late for HTML autofocus727 // Manually enable tabindex too, as this might only be applied by the interactable functionality in the background, but too late for HTML autofocus
public/scripts/templates/thirdPartyExtensionWarning.html+18 -0
@@ -0,0 +1,18 @@
1<p>
2 <em data-i18n="The URL you provided does not seem to be an official SillyTavern extension repository.">
3 The URL you provided does not seem to be an official SillyTavern extension repository.
4 </em>
5</p>
6<p class="info-block error">
7 <span data-i18n="Using third-party extensions can have unintended side effects and may pose security risks.">
8 Using third-party extensions can have unintended side effects and may pose security risks.
9 </span>
10 <span data-i18n="Always make sure you trust the source before importing an extension. We are not responsible for any damage caused by third-party extensions.">
11 Always make sure you trust the source before importing an extension. We are not responsible for any damage caused by third-party extensions.
12 </span>
13</p>
14<p>
15 <b data-i18n="Are you sure you want to proceed?">
16 Are you sure you want to proceed?
17 </b>
18</p>
src/endpoints/extensions.js+93 -49
@@ -6,7 +6,7 @@ import sanitize from 'sanitize-filename';
6import { CheckRepoActions, default as simpleGit } from 'simple-git';6import { CheckRepoActions, default as simpleGit } from 'simple-git';
77
8import { PUBLIC_DIRECTORIES } from '../constants.js';8import { PUBLIC_DIRECTORIES } from '../constants.js';
9import { getConfigValue } from '../util.js';9import { getConfigValue, isValidUrl } from '../util.js';
10import { createGitClient } from '../git/client.js';10import { createGitClient } from '../git/client.js';
1111
12const gitBackend = getConfigValue('git.backend', 'auto');12const gitBackend = getConfigValue('git.backend', 'auto');
@@ -65,6 +65,15 @@ async function checkIfRepoIsUpToDate(extensionPath) {
6565
66export const router = express.Router();66export const router = express.Router();
6767
68// Feature flag guard: don't allow calling any of the endpoints if extensions are disabled
69router.use((_, response, next) => {
70 const enabled = !!getConfigValue('extensions.enabled', true, 'boolean');
71 if (!enabled) {
72 return response.status(400).send('Bad Request: Extensions are disabled.');
73 }
74 next();
75});
76
68/**77/**
69 * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,78 * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
70 * and return extension information and path.79 * and return extension information and path.
@@ -75,11 +84,23 @@ export const router = express.Router();
75 * @returns {void}84 * @returns {void}
76 */85 */
77router.post('/install', async (request, response) => {86router.post('/install', async (request, response) => {
78 if (!request.body.url) {
79 return response.status(400).send('Bad Request: URL is required in the request body.');
80 }
81
82 try {87 try {
88 const { url, global, branch } = request.body;
89
90 if (global && !request.user.profile.admin) {
91 console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);
92 return response.status(403).send('Forbidden: No permission to install global extensions.');
93 }
94
95 if (!isValidUrl(url)) {
96 return response.status(400).send('Bad Request: A valid URL is required in the request body.');
97 }
98
99 const parsedUrl = new URL(url);
100 if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
101 return response.status(400).send('Bad Request: Only HTTP and HTTPS protocols are supported for the Extension URL.');
102 }
103
83 const git = createGitClient({ backend: gitBackend });104 const git = createGitClient({ backend: gitBackend });
84105
85 // make sure the third-party directory exists106 // make sure the third-party directory exists
@@ -91,15 +112,13 @@ router.post('/install', async (request, response) => {
91 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);112 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
92 }113 }
93114
94 const { url, global, branch } = request.body;115 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
95116 const extensionNameSanitized = sanitize(path.basename(parsedUrl.pathname, '.git'));
96 if (global && !request.user.profile.admin) {117 if (!extensionNameSanitized) {
97 console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);118 return response.status(400).send('Could not determine the extension name from the URL. Please provide a valid git repository URL.');
98 return response.status(403).send('Forbidden: No permission to install global extensions.');
99 }119 }
100120
101 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;121 const extensionPath = path.join(basePath, extensionNameSanitized);
102 const extensionPath = path.join(basePath, sanitize(path.basename(url, '.git')));
103122
104 if (fs.existsSync(extensionPath)) {123 if (fs.existsSync(extensionPath)) {
105 return response.status(409).send(`Directory already exists at ${extensionPath}`);124 return response.status(409).send(`Directory already exists at ${extensionPath}`);
@@ -109,16 +128,16 @@ router.post('/install', async (request, response) => {
109 if (branch) {128 if (branch) {
110 cloneOptions.branch = branch;129 cloneOptions.branch = branch;
111 }130 }
112 await git.clone(url, extensionPath, cloneOptions);131 await git.clone(parsedUrl.href, extensionPath, cloneOptions);
113 console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);132 console.info(`Extension has been cloned to ${extensionPath} from ${parsedUrl.href} at ${branch || '(default)'} branch`);
114133
115 const { version, author, display_name } = await getManifest(extensionPath);134 const { version, author, display_name } = await getManifest(extensionPath);
116 const folderName = path.basename(extensionPath);135 const folderName = path.basename(extensionPath);
117136
118 return response.send({ version, author, display_name, extensionPath, folderName });137 return response.send({ version, author, display_name, extensionPath, folderName });
119 } catch (error) {138 } catch (error) {
120 console.error('Importing custom content failed', error);139 console.error('Importing extension failed', error);
121 return response.status(500).send(`Server Error: ${error.message}`);140 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
122 }141 }
123});142});
124143
@@ -134,12 +153,16 @@ router.post('/install', async (request, response) => {
134 * @returns {void}153 * @returns {void}
135 */154 */
136router.post('/update', async (request, response) => {155router.post('/update', async (request, response) => {
137 if (!request.body.extensionName) {
138 return response.status(400).send('Bad Request: extensionName is required in the request body.');
139 }
140
141 try {156 try {
157 if (typeof request.body.extensionName !== 'string') {
158 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
159 }
160
142 const { extensionName, global } = request.body;161 const { extensionName, global } = request.body;
162 const extensionNameSanitized = sanitize(extensionName);
163 if (!extensionNameSanitized) {
164 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
165 }
143166
144 if (global && !request.user.profile.admin) {167 if (global && !request.user.profile.admin) {
145 console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`);168 console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`);
@@ -147,7 +170,7 @@ router.post('/update', async (request, response) => {
147 }170 }
148171
149 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;172 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
150 const extensionPath = path.join(basePath, sanitize(extensionName));173 const extensionPath = path.join(basePath, extensionNameSanitized);
151174
152 if (!fs.existsSync(extensionPath)) {175 if (!fs.existsSync(extensionPath)) {
153 return response.status(404).send(`Directory does not exist at ${extensionPath}`);176 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -179,10 +202,14 @@ router.post('/update', async (request, response) => {
179202
180router.post('/branches', async (request, response) => {203router.post('/branches', async (request, response) => {
181 try {204 try {
182 const { extensionName, global } = request.body;205 if (typeof request.body.extensionName !== 'string') {
206 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
207 }
183208
184 if (!extensionName) {209 const { extensionName, global } = request.body;
185 return response.status(400).send('Bad Request: extensionName is required in the request body.');210 const extensionNameSanitized = sanitize(extensionName);
211 if (!extensionNameSanitized) {
212 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
186 }213 }
187214
188 if (global && !request.user.profile.admin) {215 if (global && !request.user.profile.admin) {
@@ -191,7 +218,7 @@ router.post('/branches', async (request, response) => {
191 }218 }
192219
193 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;220 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
194 const extensionPath = path.join(basePath, sanitize(extensionName));221 const extensionPath = path.join(basePath, extensionNameSanitized);
195222
196 if (!fs.existsSync(extensionPath)) {223 if (!fs.existsSync(extensionPath)) {
197 return response.status(404).send(`Directory does not exist at ${extensionPath}`);224 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -224,10 +251,14 @@ router.post('/branches', async (request, response) => {
224251
225router.post('/switch', async (request, response) => {252router.post('/switch', async (request, response) => {
226 try {253 try {
227 const { extensionName, branch, global } = request.body;254 if (typeof request.body.extensionName !== 'string') {
255 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
256 }
228257
229 if (!extensionName || !branch) {258 const { extensionName, branch, global } = request.body;
230 return response.status(400).send('Bad Request: extensionName and branch are required in the request body.');259 const extensionNameSanitized = sanitize(extensionName);
260 if (!extensionNameSanitized || !branch) {
261 return response.status(400).send('Bad Request: A valid extensionName and branch are required in the request body.');
231 }262 }
232263
233 if (global && !request.user.profile.admin) {264 if (global && !request.user.profile.admin) {
@@ -236,7 +267,7 @@ router.post('/switch', async (request, response) => {
236 }267 }
237268
238 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;269 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
239 const extensionPath = path.join(basePath, sanitize(extensionName));270 const extensionPath = path.join(basePath, extensionNameSanitized);
240271
241 if (!fs.existsSync(extensionPath)) {272 if (!fs.existsSync(extensionPath)) {
242 return response.status(404).send(`Directory does not exist at ${extensionPath}`);273 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -283,10 +314,14 @@ router.post('/switch', async (request, response) => {
283314
284router.post('/move', async (request, response) => {315router.post('/move', async (request, response) => {
285 try {316 try {
286 const { extensionName, source, destination } = request.body;317 if (typeof request.body.extensionName !== 'string') {
318 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
319 }
287320
288 if (!extensionName || !source || !destination) {321 const { extensionName, source, destination } = request.body;
289 return response.status(400).send('Bad Request. Not all required parameters are provided.');322 const extensionNameSanitized = sanitize(extensionName);
323 if (!extensionNameSanitized || !source || !destination) {
324 return response.status(400).send('Bad Request: A valid extensionName, source, and destination are required in the request body.');
290 }325 }
291326
292 if (!request.user.profile.admin) {327 if (!request.user.profile.admin) {
@@ -296,8 +331,8 @@ router.post('/move', async (request, response) => {
296331
297 const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;332 const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
298 const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;333 const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
299 const sourcePath = path.join(sourceDirectory, sanitize(extensionName));334 const sourcePath = path.join(sourceDirectory, extensionNameSanitized);
300 const destinationPath = path.join(destinationDirectory, sanitize(extensionName));335 const destinationPath = path.join(destinationDirectory, extensionNameSanitized);
301336
302 if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {337 if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
303 console.error(`Source directory does not exist at ${sourcePath}`);338 console.error(`Source directory does not exist at ${sourcePath}`);
@@ -336,14 +371,19 @@ router.post('/move', async (request, response) => {
336 * @returns {void}371 * @returns {void}
337 */372 */
338router.post('/version', async (request, response) => {373router.post('/version', async (request, response) => {
339 if (!request.body.extensionName) {
340 return response.status(400).send('Bad Request: extensionName is required in the request body.');
341 }
342
343 try {374 try {
375 if (typeof request.body.extensionName !== 'string') {
376 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
377 }
378
344 const { extensionName, global } = request.body;379 const { extensionName, global } = request.body;
380 const extensionNameSanitized = sanitize(extensionName);
381 if (!extensionNameSanitized) {
382 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
383 }
384
345 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;385 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
346 const extensionPath = path.join(basePath, sanitize(extensionName));386 const extensionPath = path.join(basePath, extensionNameSanitized);
347387
348 if (!fs.existsSync(extensionPath)) {388 if (!fs.existsSync(extensionPath)) {
349 return response.status(404).send(`Directory does not exist at ${extensionPath}`);389 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -367,31 +407,35 @@ router.post('/version', async (request, response) => {
367 // get only the working branch407 // get only the working branch
368 const currentBranchName = currentBranch.current;408 const currentBranchName = currentBranch.current;
369 await git.fetch('origin');409 await git.fetch('origin');
370 console.debug(extensionName, currentBranchName, currentCommitHash);410 console.debug(extensionNameSanitized, currentBranchName, currentCommitHash);
371 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);411 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
372412
373 return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });413 return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
374 } catch (error) {414 } catch (error) {
375 console.error('Getting extension version failed', error);415 console.error('Getting extension version failed', error);
376 return response.status(500).send(`Server Error: ${error.message}`);416 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
377 }417 }
378});418});
379419
380/**420/**
381 * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.421 * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
382 *422 *
383 * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property.423 * @param {Object} request - HTTP Request object, expects a JSON body with a 'extensionName' property.
384 * @param {Object} response - HTTP Response object used to respond to the HTTP request.424 * @param {Object} response - HTTP Response object used to respond to the HTTP request.
385 *425 *
386 * @returns {void}426 * @returns {void}
387 */427 */
388router.post('/delete', async (request, response) => {428router.post('/delete', async (request, response) => {
389 if (!request.body.extensionName) {
390 return response.status(400).send('Bad Request: extensionName is required in the request body.');
391 }
392
393 try {429 try {
430 if (typeof request.body.extensionName !== 'string') {
431 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
432 }
433
394 const { extensionName, global } = request.body;434 const { extensionName, global } = request.body;
435 const extensionNameSanitized = sanitize(extensionName);
436 if (!extensionNameSanitized) {
437 return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
438 }
395439
396 if (global && !request.user.profile.admin) {440 if (global && !request.user.profile.admin) {
397 console.error(`User ${request.user.profile.handle} does not have permission to delete global extensions.`);441 console.error(`User ${request.user.profile.handle} does not have permission to delete global extensions.`);
@@ -399,7 +443,7 @@ router.post('/delete', async (request, response) => {
399 }443 }
400444
401 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;445 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
402 const extensionPath = path.join(basePath, sanitize(extensionName));446 const extensionPath = path.join(basePath, extensionNameSanitized);
403447
404 if (!fs.existsSync(extensionPath)) {448 if (!fs.existsSync(extensionPath)) {
405 return response.status(404).send(`Directory does not exist at ${extensionPath}`);449 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -410,8 +454,8 @@ router.post('/delete', async (request, response) => {
410454
411 return response.send(`Extension has been deleted at ${extensionPath}`);455 return response.send(`Extension has been deleted at ${extensionPath}`);
412 } catch (error) {456 } catch (error) {
413 console.error('Deleting custom content failed', error);457 console.error('Deleting extension failed', error);
414 return response.status(500).send(`Server Error: ${error.message}`);458 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
415 }459 }
416});460});
417461