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, +295 -87Showing whitespace changes
public/css/extensions-panel.css+11 -1
@@ -97,13 +97,23 @@ label[for="extensions_autoconnect"] {
9797 font-size: 1.05em;
9898}
9999
100100.extensions_info :is(.extension_version, .extension_author) {
101101 opacity: 0.8;
102102 font-size: 0.8em;
103103 font-weight: normal;
104104 margin-left: 2px;
105105}
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+
107117.extensions_info .extension_block a {
108118 color: var(--SmartThemeBodyColor);
109119}
public/script.js+4 -0
@@ -7953,6 +7953,10 @@ export async function getSettings(initLoaderHandle = null) {
79537953 const isVersionChanged = settings.currentVersion !== currentVersion;
79547954 await loadExtensionSettings(settings, isVersionChanged, enableAutoUpdate);
79557955 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');
79567960 }
79577961
79587962 firstRun = !!settings.firstRun;
public/scripts/extensions.js+108 -3
@@ -61,6 +61,19 @@ let manifests = {};
6161 */
6262const 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+ */
69+export 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+
6477let requiresReload = false;
6578let stateChanged = false;
6679let saveMetadataTimeout = null;
@@ -928,6 +941,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
928941 ${originHtml}
929942 <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
930943 <span class="extension_name">${DOMPurify.sanitize(displayName)}</span>
944+ <span class="extension_author"></span>
931945 <span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>
932946 ${modulesInfo}
933947 </span>
@@ -1557,9 +1571,53 @@ async function switchExtensionBranch(extensionName, isGlobal, branch) {
15571571 * Installs a third-party extension via the API.
15581572 * @param {string} url Extension repository URL
15591573 * @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
15611576 */
15621577export 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+
15631621 console.debug('Extension installation started', url);
15641622
15651623 toastr.info(t`Please wait...`, t`Installing extension`);
@@ -1578,11 +1636,11 @@ export async function installExtension(url, global, branch = '') {
15781636 const text = await request.text();
15791637 toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 });
15801638 console.error('Extension installation failed', request.status, request.statusText, text);
15811639 return false;
15821640 }
15831641
15841642 const response = await request.json();
15851643 toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`);
15861644 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);
15871645 await loadExtensionSettings({}, false, false);
15881646 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response);
@@ -1591,6 +1649,8 @@ export async function installExtension(url, global, branch = '') {
15911649 const extensionName = `third-party/${response.folderName}`;
15921650 await callExtensionHook(extensionName, 'install');
15931651 }
1652+
1653+ return true;
15941654}
15951655
15961656/**
@@ -1701,6 +1761,18 @@ async function checkForUpdatesManual(sortFn, abortSignal) {
17011761 }
17021762 }
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+
17041776 const versionElement = extensionBlock.querySelector('.extension_version');
17051777 if (versionElement) {
17061778 versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`;
@@ -2057,6 +2129,39 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
20572129 await installExtension(url, global, branchName);
20582130}
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+ */
2136+export 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+ */
2146+export 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+
20602165export async function initExtensions() {
20612166 await addExtensionsButtonAndMenu();
20622167 $('#extensionsMenuButton').css('display', 'flex');
public/scripts/extensions/assets/index.js+27 -35
@@ -5,7 +5,7 @@ TODO:
55
66import { DOMPurify } from '../../../lib.js';
77import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
88import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';
99import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
1010import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
1111import { accountStorage } from '../../util/AccountStorage.js';
@@ -60,35 +60,6 @@ const KNOWN_TYPES = {
6060 'blip': t`Blip sounds`,
6161};
6262
63-const 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- */
73-function 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-
9263async function downloadAssetsList(url) {
9364 updateCurrentAssets().then(async function () {
9465 fetch(url, { cache: 'no-cache' })
@@ -153,7 +124,15 @@ async function downloadAssetsList(url) {
153124 element.off('click');
154125 label.removeClass('fa-download');
155126 this.classList.add('asset-download-button-loading');
156127 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+ }
157136 label.addClass('fa-check');
158137 this.classList.remove('asset-download-button-loading');
159138 element.on('click', assetDelete);
@@ -248,8 +227,15 @@ async function downloadAssetsList(url) {
248227
249228 assetBlock.addClass('asset-block');
250229
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 {
251236 assetTypeMenu.append(assetBlock);
252237 }
238+ }
253239 assetTypeMenu.appendTo('#assets_menu');
254240 assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
255241 }
@@ -322,9 +308,9 @@ async function installAsset(url, assetType, filename) {
322308 try {
323309 if (category === 'extension') {
324310 console.debug(DEBUG_PREFIX, 'Installing extension ', url);
325311 const result = await installExtension(url, false);
326312 console.debug(DEBUG_PREFIX, 'Extension installed.');
327313 return result;
328314 }
329315
330316 const body = { url, category, filename };
@@ -343,10 +329,12 @@ async function installAsset(url, assetType, filename) {
343329 await processDroppedFiles([file]);
344330 console.debug(DEBUG_PREFIX, 'Character downloaded.');
345331 }
332+ return true;
346333 }
334+ return false;
347335 } catch (err) {
348336 console.log(err);
349337 return []false;
350338 }
351339}
352340
@@ -398,9 +386,13 @@ async function openCharacterBrowser(forceDefault) {
398386
399387 downloadButton.toggle(!isInstalled).on('click', async () => {
400388 downloadButton.toggleClass('fa-download fa-spinner fa-spin');
401389 const result = await installAsset(character.url, 'character', character.id);
390+ if (result) {
402391 downloadButton.hide();
403392 checkMark.show();
393+ } else {
394+ downloadButton.toggleClass('fa-download fa-spinner fa-spin');
395+ }
404396 });
405397
406398 checkMark.toggle(isInstalled);
public/scripts/extensions/assets/installation.html+18 -0
@@ -2,3 +2,21 @@
22 <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>
33 <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>
44</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 @@
2727 margin-bottom: 0.25em;
2828}
2929
30+.assets-list-div h2 {
31+ margin: 0;
32+ font-size: 1.1em;
33+}
34+
3035.assets-list-div h3 {
3136 text-transform: capitalize;
3237}
3338
3439.assets-list-div i.asset-block a {
3540 color: inherit;
3641}
3742
3843.assets-list-div>i .asset-block {
3944 display: flex;
4045 flex-direction: row;
4146 align-items: center;
@@ -46,7 +51,7 @@
4651 border-bottom: 1px solid var(--SmartThemeBorderColor);
4752}
4853
4954.assets-list-div i.asset-block span:first-of-type {
5055 font-weight: bold;
5156}
5257
@@ -198,3 +203,11 @@
198203.asset-name>b {
199204 font-weight: 600;
200205}
206+
207+div:is(.assets-list-extensions-official, .assets-list-extensions-community):has(.assets-list-extensions:empty) {
208+ display: none;
209+}
210+
211+div: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 {
718718 }
719719 }
720720
721+ if (!control) {
722+ return;
723+ }
724+
721725 if (applyAutoFocus) {
722726 control.setAttribute('autofocus', '');
723727 // 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+89 -45
@@ -6,7 +6,7 @@ import sanitize from 'sanitize-filename';
66import { CheckRepoActions, default as simpleGit } from 'simple-git';
77
88import { PUBLIC_DIRECTORIES } from '../constants.js';
99import { getConfigValue, isValidUrl } from '../util.js';
1010import { createGitClient } from '../git/client.js';
1111
1212const gitBackend = getConfigValue('git.backend', 'auto');
@@ -65,6 +65,15 @@ async function checkIfRepoIsUpToDate(extensionPath) {
6565
6666export const router = express.Router();
6767
68+// Feature flag guard: don't allow calling any of the endpoints if extensions are disabled
69+router.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+
6877/**
6978 * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
7079 * and return extension information and path.
@@ -75,11 +84,23 @@ export const router = express.Router();
7584 * @returns {void}
7685 */
7786router.post('/install', async (request, response) => {
78- if (!request.body.url) {
87+ try {
79- return response.status(400).send('Bad Request: URL is required in the request body.');
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.');
80102 }
81103
82- try {
83104 const git = createGitClient({ backend: gitBackend });
84105
85106 // make sure the third-party directory exists
@@ -91,15 +112,13 @@ router.post('/install', async (request, response) => {
91112 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
92113 }
93114
94115 const {basePath url,= global, branch? }PUBLIC_DIRECTORIES.globalExtensions =: request.bodyuser.directories.extensions;
95-
116+ const extensionNameSanitized = sanitize(path.basename(parsedUrl.pathname, '.git'));
96117 if (global && !request.user.profile.adminextensionNameSanitized) {
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.');
99119 }
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
104123 if (fs.existsSync(extensionPath)) {
105124 return response.status(409).send(`Directory already exists at ${extensionPath}`);
@@ -109,16 +128,16 @@ router.post('/install', async (request, response) => {
109128 if (branch) {
110129 cloneOptions.branch = branch;
111130 }
112131 await git.clone(urlparsedUrl.href, extensionPath, cloneOptions);
113132 console.info(`Extension has been cloned to ${extensionPath} from ${urlparsedUrl.href} at ${branch || '(default)'} branch`);
114133
115134 const { version, author, display_name } = await getManifest(extensionPath);
116135 const folderName = path.basename(extensionPath);
117136
118137 return response.send({ version, author, display_name, extensionPath, folderName });
119138 } catch (error) {
120139 console.error('Importing custom contentextension failed', error);
121140 return response.status(500).send(`'Internal Server Error:. ${errorCheck the server logs for more details.message}`');
122141 }
123142});
124143
@@ -134,12 +153,16 @@ router.post('/install', async (request, response) => {
134153 * @returns {void}
135154 */
136155router.post('/update', async (request, response) => {
137- if (!request.body.extensionName) {
156+ try {
138- return response.status(400).send('Bad Request: extensionName is required in the request body.');
157+ if (typeof request.body.extensionName !== 'string') {
158+ return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
139159 }
140160
141- try {
142161 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
144167 if (global && !request.user.profile.admin) {
145168 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) => {
147170 }
148171
149172 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
150173 const extensionPath = path.join(basePath, sanitize(extensionName)extensionNameSanitized);
151174
152175 if (!fs.existsSync(extensionPath)) {
153176 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -179,10 +202,14 @@ router.post('/update', async (request, response) => {
179202
180203router.post('/branches', async (request, response) => {
181204 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.');
186213 }
187214
188215 if (global && !request.user.profile.admin) {
@@ -191,7 +218,7 @@ router.post('/branches', async (request, response) => {
191218 }
192219
193220 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
194221 const extensionPath = path.join(basePath, sanitize(extensionName)extensionNameSanitized);
195222
196223 if (!fs.existsSync(extensionPath)) {
197224 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -224,10 +251,14 @@ router.post('/branches', async (request, response) => {
224251
225252router.post('/switch', async (request, response) => {
226253 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.');
231262 }
232263
233264 if (global && !request.user.profile.admin) {
@@ -236,7 +267,7 @@ router.post('/switch', async (request, response) => {
236267 }
237268
238269 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
239270 const extensionPath = path.join(basePath, sanitize(extensionName)extensionNameSanitized);
240271
241272 if (!fs.existsSync(extensionPath)) {
242273 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -283,10 +314,14 @@ router.post('/switch', async (request, response) => {
283314
284315router.post('/move', async (request, response) => {
285316 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.');
290325 }
291326
292327 if (!request.user.profile.admin) {
@@ -296,8 +331,8 @@ router.post('/move', async (request, response) => {
296331
297332 const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
298333 const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
299334 const sourcePath = path.join(sourceDirectory, sanitize(extensionName)extensionNameSanitized);
300335 const destinationPath = path.join(destinationDirectory, sanitize(extensionName)extensionNameSanitized);
301336
302337 if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
303338 console.error(`Source directory does not exist at ${sourcePath}`);
@@ -336,14 +371,19 @@ router.post('/move', async (request, response) => {
336371 * @returns {void}
337372 */
338373router.post('/version', async (request, response) => {
339- if (!request.body.extensionName) {
374+ try {
340- return response.status(400).send('Bad Request: extensionName is required in the request body.');
375+ if (typeof request.body.extensionName !== 'string') {
376+ return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
341377 }
342378
343- try {
344379 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+
345385 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
346386 const extensionPath = path.join(basePath, sanitize(extensionName)extensionNameSanitized);
347387
348388 if (!fs.existsSync(extensionPath)) {
349389 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -367,31 +407,35 @@ router.post('/version', async (request, response) => {
367407 // get only the working branch
368408 const currentBranchName = currentBranch.current;
369409 await git.fetch('origin');
370410 console.debug(extensionNameextensionNameSanitized, currentBranchName, currentCommitHash);
371411 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
372412
373413 return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
374414 } catch (error) {
375415 console.error('Getting extension version failed', error);
376416 return response.status(500).send(`'Internal Server Error:. ${errorCheck the server logs for more details.message}`');
377417 }
378418});
379419
380420/**
381421 * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
382422 *
383423 * @param {Object} request - HTTP Request object, expects a JSON body with a 'urlextensionName' property.
384424 * @param {Object} response - HTTP Response object used to respond to the HTTP request.
385425 *
386426 * @returns {void}
387427 */
388428router.post('/delete', async (request, response) => {
389- if (!request.body.extensionName) {
429+ try {
390- return response.status(400).send('Bad Request: extensionName is required in the request body.');
430+ if (typeof request.body.extensionName !== 'string') {
431+ return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
391432 }
392433
393- try {
394434 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
396440 if (global && !request.user.profile.admin) {
397441 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) => {
399443 }
400444
401445 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
402446 const extensionPath = path.join(basePath, sanitize(extensionName)extensionNameSanitized);
403447
404448 if (!fs.existsSync(extensionPath)) {
405449 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -410,8 +454,8 @@ router.post('/delete', async (request, response) => {
410454
411455 return response.send(`Extension has been deleted at ${extensionPath}`);
412456 } catch (error) {
413457 console.error('Deleting custom contentextension failed', error);
414458 return response.status(500).send(`'Internal Server Error:. ${errorCheck the server logs for more details.message}`');
415459 }
416460});
417461