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>
This commit is contained in:
Cohee
2026-04-30 23:31:50 +03:00
committed by GitHub
parent 45f2951854
commit 5512473b29
9 changed files with 304 additions and 96 deletions
+11 -1
View File
@@ -97,13 +97,23 @@ label[for="extensions_autoconnect"] {
font-size: 1.05em; font-size: 1.05em;
} }
.extensions_info .extension_version { .extensions_info :is(.extension_version, .extension_author) {
opacity: 0.8; opacity: 0.8;
font-size: 0.8em; font-size: 0.8em;
font-weight: normal; font-weight: normal;
margin-left: 2px; margin-left: 2px;
} }
.extensions_info :is(.extension_version, .extension_author):empty {
display: none;
}
.extensions_info .extension_author {
display: inline-flex;
gap: 2px;
align-items: baseline;
}
.extensions_info .extension_block a { .extensions_info .extension_block a {
color: var(--SmartThemeBodyColor); color: var(--SmartThemeBodyColor);
} }
+4
View File
@@ -7953,6 +7953,10 @@ export async function getSettings(initLoaderHandle = null) {
const isVersionChanged = settings.currentVersion !== currentVersion; const isVersionChanged = settings.currentVersion !== currentVersion;
await loadExtensionSettings(settings, isVersionChanged, enableAutoUpdate); await loadExtensionSettings(settings, isVersionChanged, enableAutoUpdate);
await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED); await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED);
} else {
Object.assign(extension_settings, (settings.extension_settings ?? {}));
$('#third_party_extension_button').addClass('disabled');
$('#extensions_details').addClass('disabled');
} }
firstRun = !!settings.firstRun; firstRun = !!settings.firstRun;
+108 -3
View File
@@ -61,6 +61,19 @@ let manifests = {};
*/ */
const defaultUrl = 'http://localhost:5100'; const defaultUrl = 'http://localhost:5100';
/**
* Checks if the extension is officially supported by its URL pattern.
* @param {string} url URL to check
* @returns {boolean} True if the URL matches the pattern, false otherwise (or not a valid URL)
*/
export const isOfficialExtension = (url) => {
try {
return /^https:\/\/github\.com\/SillyTavern\/(.+)$/i.test(new URL(url).href);
} catch (e) {
return false;
}
};
let requiresReload = false; let requiresReload = false;
let stateChanged = false; let stateChanged = false;
let saveMetadataTimeout = null; let saveMetadataTimeout = null;
@@ -928,6 +941,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
${originHtml} ${originHtml}
<span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}"> <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
<span class="extension_name">${DOMPurify.sanitize(displayName)}</span> <span class="extension_name">${DOMPurify.sanitize(displayName)}</span>
<span class="extension_author"></span>
<span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span> <span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>
${modulesInfo} ${modulesInfo}
</span> </span>
@@ -1557,9 +1571,53 @@ async function switchExtensionBranch(extensionName, isGlobal, branch) {
* Installs a third-party extension via the API. * Installs a third-party extension via the API.
* @param {string} url Extension repository URL * @param {string} url Extension repository URL
* @param {boolean} global Is the extension global? * @param {boolean} global Is the extension global?
* @returns {Promise<void>} * @param {string} [branch] Optional branch to install, if not provided the default branch will be used
* @returns {Promise<boolean>} True if the extension was installed successfully, false otherwise
*/ */
export async function installExtension(url, global, branch = '') { export async function installExtension(url, global, branch = '') {
try {
const parsedUrl = new URL(url);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('Invalid URL protocol');
}
// Normalize the URL (resolve relative paths, remove redundant segments, etc.)
url = parsedUrl.href;
} catch (error) {
console.error('Invalid URL:', error);
toastr.error(t`Only valid HTTP and HTTPS URLs are allowed.`, t`Invalid URL`);
return false;
}
if (!isOfficialExtension(url)) {
const extensionInstallationWarningKey = 'extensionInstallationWarningShown';
if (accountStorage.getItem(extensionInstallationWarningKey)) {
console.debug('Bypassed URL check for third-party extension (account preference).', url);
} else {
let dismissWarning = false;
const confirmation = await Popup.show.confirm(
t`Install a third-party extension?`,
await renderTemplateAsync('thirdPartyExtensionWarning'),
{
customInputs: [{ id: 'dontAskAgain', type: 'checkbox', label: t`Don't show this warning again`, defaultState: false }],
onClose: (popup) => {
if (!popup.result) {
return;
}
dismissWarning = Boolean(popup.inputResults?.get('dontAskAgain') ?? false);
},
okButton: t`Yes, install it`,
cancelButton: t`No, cancel`,
});
if (!confirmation) {
return false;
}
if (dismissWarning) {
accountStorage.setItem(extensionInstallationWarningKey, '1');
}
}
}
console.debug('Extension installation started', url); console.debug('Extension installation started', url);
toastr.info(t`Please wait...`, t`Installing extension`); toastr.info(t`Please wait...`, t`Installing extension`);
@@ -1578,11 +1636,11 @@ export async function installExtension(url, global, branch = '') {
const text = await request.text(); const text = await request.text();
toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 }); toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 });
console.error('Extension installation failed', request.status, request.statusText, text); console.error('Extension installation failed', request.status, request.statusText, text);
return; return false;
} }
const response = await request.json(); const response = await request.json();
toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`); toastr.success(t`Extension '${response.display_name}' has been installed successfully!`, t`Extension installation successful`);
console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`); console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);
await loadExtensionSettings({}, false, false); await loadExtensionSettings({}, false, false);
await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response); await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response);
@@ -1591,6 +1649,8 @@ export async function installExtension(url, global, branch = '') {
const extensionName = `third-party/${response.folderName}`; const extensionName = `third-party/${response.folderName}`;
await callExtensionHook(extensionName, 'install'); await callExtensionHook(extensionName, 'install');
} }
return true;
} }
/** /**
@@ -1701,6 +1761,18 @@ async function checkForUpdatesManual(sortFn, abortSignal) {
} }
} }
const authorElement = extensionBlock.querySelector('.extension_author');
if (authorElement) {
const author = getAuthorFromUrl(origin) || EMPTY_AUTHOR;
if (author.name) {
const icon = document.createElement('i');
icon.classList.add('fa-solid', 'fa-at', 'fa-xs');
const name = document.createElement('span');
name.textContent = author.name;
authorElement.append(icon, name);
}
}
const versionElement = extensionBlock.querySelector('.extension_version'); const versionElement = extensionBlock.querySelector('.extension_version');
if (versionElement) { if (versionElement) {
versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`; versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`;
@@ -2057,6 +2129,39 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
await installExtension(url, global, branchName); await installExtension(url, global, branchName);
} }
/**
* Sentinel value representing an empty author, used when author information cannot be extracted from a URL.
* @type {{name: string, url: string}}
*/
export const EMPTY_AUTHOR = Object.freeze({
name: '',
url: '',
});
/**
* Extracts the repository author from a given URL.
* @param {string} url - The URL of the repository.
* @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found.
*/
export function getAuthorFromUrl(url) {
const result = structuredClone(EMPTY_AUTHOR);
try {
const parsedUrl = new URL(url);
const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0);
// TODO: Handle non-GitHub URLs if needed
if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) {
result.name = pathSegments[0];
result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`;
}
} catch (error) {
console.debug('Error parsing URL:', error);
}
return result;
}
export async function initExtensions() { export async function initExtensions() {
await addExtensionsButtonAndMenu(); await addExtensionsButtonAndMenu();
$('#extensionsMenuButton').css('display', 'flex'); $('#extensionsMenuButton').css('display', 'flex');
+30 -38
View File
@@ -5,7 +5,7 @@ TODO:
import { DOMPurify } from '../../../lib.js'; import { DOMPurify } from '../../../lib.js';
import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js'; import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
import { deleteExtension, extensionNames, getContext, installExtension, renderExtensionTemplateAsync } from '../../extensions.js'; import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';
import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js'; import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
import { executeSlashCommandsWithOptions } from '../../slash-commands.js'; import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
import { accountStorage } from '../../util/AccountStorage.js'; import { accountStorage } from '../../util/AccountStorage.js';
@@ -60,35 +60,6 @@ const KNOWN_TYPES = {
'blip': t`Blip sounds`, 'blip': t`Blip sounds`,
}; };
const EMPTY_AUTHOR = {
name: '',
url: '',
};
/**
* Extracts the repository author from a given URL.
* @param {string} url - The URL of the repository.
* @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found.
*/
function getAuthorFromUrl(url) {
const result = structuredClone(EMPTY_AUTHOR);
try {
const parsedUrl = new URL(url);
const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0);
// TODO: Handle non-GitHub URLs if needed
if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) {
result.name = pathSegments[0];
result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`;
}
} catch (error) {
console.debug(DEBUG_PREFIX, 'Error parsing URL:', error);
}
return result;
}
async function downloadAssetsList(url) { async function downloadAssetsList(url) {
updateCurrentAssets().then(async function () { updateCurrentAssets().then(async function () {
fetch(url, { cache: 'no-cache' }) fetch(url, { cache: 'no-cache' })
@@ -153,7 +124,15 @@ async function downloadAssetsList(url) {
element.off('click'); element.off('click');
label.removeClass('fa-download'); label.removeClass('fa-download');
this.classList.add('asset-download-button-loading'); this.classList.add('asset-download-button-loading');
await installAsset(asset.url, assetType, asset.id); const result = await installAsset(asset.url, assetType, asset.id);
if (!result) {
this.classList.remove('asset-download-button-loading');
label.addClass('fa-download');
label.removeClass('fa-spinner');
label.removeClass('fa-spin');
element.on('click', assetInstall);
return;
}
label.addClass('fa-check'); label.addClass('fa-check');
this.classList.remove('asset-download-button-loading'); this.classList.remove('asset-download-button-loading');
element.on('click', assetDelete); element.on('click', assetDelete);
@@ -248,7 +227,14 @@ async function downloadAssetsList(url) {
assetBlock.addClass('asset-block'); assetBlock.addClass('asset-block');
assetTypeMenu.append(assetBlock); if (assetType === 'extension') {
const extensionBlockList = isOfficialExtension(asset.url)
? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions')
: assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions');
extensionBlockList.append(assetBlock);
} else {
assetTypeMenu.append(assetBlock);
}
} }
assetTypeMenu.appendTo('#assets_menu'); assetTypeMenu.appendTo('#assets_menu');
assetTypeMenu.on('click', 'a.asset_preview', previewAsset); assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
@@ -322,9 +308,9 @@ async function installAsset(url, assetType, filename) {
try { try {
if (category === 'extension') { if (category === 'extension') {
console.debug(DEBUG_PREFIX, 'Installing extension ', url); console.debug(DEBUG_PREFIX, 'Installing extension ', url);
await installExtension(url, false); const result = await installExtension(url, false);
console.debug(DEBUG_PREFIX, 'Extension installed.'); console.debug(DEBUG_PREFIX, 'Extension installed.');
return; return result;
} }
const body = { url, category, filename }; const body = { url, category, filename };
@@ -343,10 +329,12 @@ async function installAsset(url, assetType, filename) {
await processDroppedFiles([file]); await processDroppedFiles([file]);
console.debug(DEBUG_PREFIX, 'Character downloaded.'); console.debug(DEBUG_PREFIX, 'Character downloaded.');
} }
return true;
} }
return false;
} catch (err) { } catch (err) {
console.log(err); console.log(err);
return []; return false;
} }
} }
@@ -398,9 +386,13 @@ async function openCharacterBrowser(forceDefault) {
downloadButton.toggle(!isInstalled).on('click', async () => { downloadButton.toggle(!isInstalled).on('click', async () => {
downloadButton.toggleClass('fa-download fa-spinner fa-spin'); downloadButton.toggleClass('fa-download fa-spinner fa-spin');
await installAsset(character.url, 'character', character.id); const result = await installAsset(character.url, 'character', character.id);
downloadButton.hide(); if (result) {
checkMark.show(); downloadButton.hide();
checkMark.show();
} else {
downloadButton.toggleClass('fa-download fa-spinner fa-spin');
}
}); });
checkMark.toggle(isInstalled); checkMark.toggle(isInstalled);
@@ -2,3 +2,21 @@
<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> <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>
<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> <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>
</div> </div>
<div class="assets-list-extensions-official">
<h2 data-i18n="Official Extensions">Official Extensions</h2>
<div class="info-block hint">
<small class="assets-list-description" data-i18n="These extensions are maintained by the SillyTavern team.">
These extensions are maintained by the SillyTavern team.
</small>
</div>
<div class="assets-list-extensions"></div>
</div>
<div class="assets-list-extensions-community">
<h2 data-i18n="Community Extensions">Community Extensions</h2>
<div class="info-block warning">
<small data-i18n="Community extensions are not reviewed or verified by the SillyTavern team. Please exercise caution when installing.">
Community extensions are not reviewed or verified by the SillyTavern team. Please exercise caution when installing.
</small>
</div>
<div class="assets-list-extensions"></div>
</div>
+16 -3
View File
@@ -27,15 +27,20 @@
margin-bottom: 0.25em; margin-bottom: 0.25em;
} }
.assets-list-div h2 {
margin: 0;
font-size: 1.1em;
}
.assets-list-div h3 { .assets-list-div h3 {
text-transform: capitalize; text-transform: capitalize;
} }
.assets-list-div i a { .assets-list-div .asset-block a {
color: inherit; color: inherit;
} }
.assets-list-div>i { .assets-list-div .asset-block {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -46,7 +51,7 @@
border-bottom: 1px solid var(--SmartThemeBorderColor); border-bottom: 1px solid var(--SmartThemeBorderColor);
} }
.assets-list-div i span:first-of-type { .assets-list-div .asset-block span:first-of-type {
font-weight: bold; font-weight: bold;
} }
@@ -198,3 +203,11 @@
.asset-name>b { .asset-name>b {
font-weight: 600; font-weight: 600;
} }
div:is(.assets-list-extensions-official, .assets-list-extensions-community):has(.assets-list-extensions:empty) {
display: none;
}
div:is(.assets-list-extensions-official, .assets-list-extensions-community) {
margin-top: 10px;
}
+4
View File
@@ -718,6 +718,10 @@ export class Popup {
} }
} }
if (!control) {
return;
}
if (applyAutoFocus) { if (applyAutoFocus) {
control.setAttribute('autofocus', ''); control.setAttribute('autofocus', '');
// Manually enable tabindex too, as this might only be applied by the interactable functionality in the background, but too late for HTML autofocus // Manually enable tabindex too, as this might only be applied by the interactable functionality in the background, but too late for HTML autofocus
@@ -0,0 +1,18 @@
<p>
<em data-i18n="The URL you provided does not seem to be an official SillyTavern extension repository.">
The URL you provided does not seem to be an official SillyTavern extension repository.
</em>
</p>
<p class="info-block error">
<span data-i18n="Using third-party extensions can have unintended side effects and may pose security risks.">
Using third-party extensions can have unintended side effects and may pose security risks.
</span>
<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.">
Always make sure you trust the source before importing an extension. We are not responsible for any damage caused by third-party extensions.
</span>
</p>
<p>
<b data-i18n="Are you sure you want to proceed?">
Are you sure you want to proceed?
</b>
</p>
+93 -49
View File
@@ -6,7 +6,7 @@ import sanitize from 'sanitize-filename';
import { CheckRepoActions, default as simpleGit } from 'simple-git'; import { CheckRepoActions, default as simpleGit } from 'simple-git';
import { PUBLIC_DIRECTORIES } from '../constants.js'; import { PUBLIC_DIRECTORIES } from '../constants.js';
import { getConfigValue } from '../util.js'; import { getConfigValue, isValidUrl } from '../util.js';
import { createGitClient } from '../git/client.js'; import { createGitClient } from '../git/client.js';
const gitBackend = getConfigValue('git.backend', 'auto'); const gitBackend = getConfigValue('git.backend', 'auto');
@@ -65,6 +65,15 @@ async function checkIfRepoIsUpToDate(extensionPath) {
export const router = express.Router(); export const router = express.Router();
// Feature flag guard: don't allow calling any of the endpoints if extensions are disabled
router.use((_, response, next) => {
const enabled = !!getConfigValue('extensions.enabled', true, 'boolean');
if (!enabled) {
return response.status(400).send('Bad Request: Extensions are disabled.');
}
next();
});
/** /**
* HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest, * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest,
* and return extension information and path. * and return extension information and path.
@@ -75,11 +84,23 @@ export const router = express.Router();
* @returns {void} * @returns {void}
*/ */
router.post('/install', async (request, response) => { router.post('/install', async (request, response) => {
if (!request.body.url) {
return response.status(400).send('Bad Request: URL is required in the request body.');
}
try { try {
const { url, global, branch } = request.body;
if (global && !request.user.profile.admin) {
console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);
return response.status(403).send('Forbidden: No permission to install global extensions.');
}
if (!isValidUrl(url)) {
return response.status(400).send('Bad Request: A valid URL is required in the request body.');
}
const parsedUrl = new URL(url);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return response.status(400).send('Bad Request: Only HTTP and HTTPS protocols are supported for the Extension URL.');
}
const git = createGitClient({ backend: gitBackend }); const git = createGitClient({ backend: gitBackend });
// make sure the third-party directory exists // make sure the third-party directory exists
@@ -91,15 +112,13 @@ router.post('/install', async (request, response) => {
fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions); fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
} }
const { url, global, branch } = request.body; const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
const extensionNameSanitized = sanitize(path.basename(parsedUrl.pathname, '.git'));
if (global && !request.user.profile.admin) { if (!extensionNameSanitized) {
console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`); return response.status(400).send('Could not determine the extension name from the URL. Please provide a valid git repository URL.');
return response.status(403).send('Forbidden: No permission to install global extensions.');
} }
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; const extensionPath = path.join(basePath, extensionNameSanitized);
const extensionPath = path.join(basePath, sanitize(path.basename(url, '.git')));
if (fs.existsSync(extensionPath)) { if (fs.existsSync(extensionPath)) {
return response.status(409).send(`Directory already exists at ${extensionPath}`); return response.status(409).send(`Directory already exists at ${extensionPath}`);
@@ -109,16 +128,16 @@ router.post('/install', async (request, response) => {
if (branch) { if (branch) {
cloneOptions.branch = branch; cloneOptions.branch = branch;
} }
await git.clone(url, extensionPath, cloneOptions); await git.clone(parsedUrl.href, extensionPath, cloneOptions);
console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`); console.info(`Extension has been cloned to ${extensionPath} from ${parsedUrl.href} at ${branch || '(default)'} branch`);
const { version, author, display_name } = await getManifest(extensionPath); const { version, author, display_name } = await getManifest(extensionPath);
const folderName = path.basename(extensionPath); const folderName = path.basename(extensionPath);
return response.send({ version, author, display_name, extensionPath, folderName }); return response.send({ version, author, display_name, extensionPath, folderName });
} catch (error) { } catch (error) {
console.error('Importing custom content failed', error); console.error('Importing extension failed', error);
return response.status(500).send(`Server Error: ${error.message}`); return response.status(500).send('Internal Server Error. Check the server logs for more details.');
} }
}); });
@@ -134,12 +153,16 @@ router.post('/install', async (request, response) => {
* @returns {void} * @returns {void}
*/ */
router.post('/update', async (request, response) => { router.post('/update', async (request, response) => {
if (!request.body.extensionName) {
return response.status(400).send('Bad Request: extensionName is required in the request body.');
}
try { try {
if (typeof request.body.extensionName !== 'string') {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
const { extensionName, global } = request.body; const { extensionName, global } = request.body;
const extensionNameSanitized = sanitize(extensionName);
if (!extensionNameSanitized) {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
if (global && !request.user.profile.admin) { if (global && !request.user.profile.admin) {
console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`); 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) => {
} }
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
const extensionPath = path.join(basePath, sanitize(extensionName)); const extensionPath = path.join(basePath, extensionNameSanitized);
if (!fs.existsSync(extensionPath)) { if (!fs.existsSync(extensionPath)) {
return response.status(404).send(`Directory does not exist at ${extensionPath}`); return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -179,10 +202,14 @@ router.post('/update', async (request, response) => {
router.post('/branches', async (request, response) => { router.post('/branches', async (request, response) => {
try { try {
const { extensionName, global } = request.body; if (typeof request.body.extensionName !== 'string') {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
if (!extensionName) { const { extensionName, global } = request.body;
return response.status(400).send('Bad Request: extensionName is required in the request body.'); const extensionNameSanitized = sanitize(extensionName);
if (!extensionNameSanitized) {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
} }
if (global && !request.user.profile.admin) { if (global && !request.user.profile.admin) {
@@ -191,7 +218,7 @@ router.post('/branches', async (request, response) => {
} }
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
const extensionPath = path.join(basePath, sanitize(extensionName)); const extensionPath = path.join(basePath, extensionNameSanitized);
if (!fs.existsSync(extensionPath)) { if (!fs.existsSync(extensionPath)) {
return response.status(404).send(`Directory does not exist at ${extensionPath}`); return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -224,10 +251,14 @@ router.post('/branches', async (request, response) => {
router.post('/switch', async (request, response) => { router.post('/switch', async (request, response) => {
try { try {
const { extensionName, branch, global } = request.body; if (typeof request.body.extensionName !== 'string') {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
if (!extensionName || !branch) { const { extensionName, branch, global } = request.body;
return response.status(400).send('Bad Request: extensionName and branch are required in the request body.'); const extensionNameSanitized = sanitize(extensionName);
if (!extensionNameSanitized || !branch) {
return response.status(400).send('Bad Request: A valid extensionName and branch are required in the request body.');
} }
if (global && !request.user.profile.admin) { if (global && !request.user.profile.admin) {
@@ -236,7 +267,7 @@ router.post('/switch', async (request, response) => {
} }
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
const extensionPath = path.join(basePath, sanitize(extensionName)); const extensionPath = path.join(basePath, extensionNameSanitized);
if (!fs.existsSync(extensionPath)) { if (!fs.existsSync(extensionPath)) {
return response.status(404).send(`Directory does not exist at ${extensionPath}`); return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -283,10 +314,14 @@ router.post('/switch', async (request, response) => {
router.post('/move', async (request, response) => { router.post('/move', async (request, response) => {
try { try {
const { extensionName, source, destination } = request.body; if (typeof request.body.extensionName !== 'string') {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
if (!extensionName || !source || !destination) { const { extensionName, source, destination } = request.body;
return response.status(400).send('Bad Request. Not all required parameters are provided.'); const extensionNameSanitized = sanitize(extensionName);
if (!extensionNameSanitized || !source || !destination) {
return response.status(400).send('Bad Request: A valid extensionName, source, and destination are required in the request body.');
} }
if (!request.user.profile.admin) { if (!request.user.profile.admin) {
@@ -296,8 +331,8 @@ router.post('/move', async (request, response) => {
const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
const sourcePath = path.join(sourceDirectory, sanitize(extensionName)); const sourcePath = path.join(sourceDirectory, extensionNameSanitized);
const destinationPath = path.join(destinationDirectory, sanitize(extensionName)); const destinationPath = path.join(destinationDirectory, extensionNameSanitized);
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) { if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
console.error(`Source directory does not exist at ${sourcePath}`); console.error(`Source directory does not exist at ${sourcePath}`);
@@ -336,14 +371,19 @@ router.post('/move', async (request, response) => {
* @returns {void} * @returns {void}
*/ */
router.post('/version', async (request, response) => { router.post('/version', async (request, response) => {
if (!request.body.extensionName) {
return response.status(400).send('Bad Request: extensionName is required in the request body.');
}
try { try {
if (typeof request.body.extensionName !== 'string') {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
const { extensionName, global } = request.body; const { extensionName, global } = request.body;
const extensionNameSanitized = sanitize(extensionName);
if (!extensionNameSanitized) {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
const extensionPath = path.join(basePath, sanitize(extensionName)); const extensionPath = path.join(basePath, extensionNameSanitized);
if (!fs.existsSync(extensionPath)) { if (!fs.existsSync(extensionPath)) {
return response.status(404).send(`Directory does not exist at ${extensionPath}`); return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -367,31 +407,35 @@ router.post('/version', async (request, response) => {
// get only the working branch // get only the working branch
const currentBranchName = currentBranch.current; const currentBranchName = currentBranch.current;
await git.fetch('origin'); await git.fetch('origin');
console.debug(extensionName, currentBranchName, currentCommitHash); console.debug(extensionNameSanitized, currentBranchName, currentCommitHash);
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl }); return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
} catch (error) { } catch (error) {
console.error('Getting extension version failed', error); console.error('Getting extension version failed', error);
return response.status(500).send(`Server Error: ${error.message}`); return response.status(500).send('Internal Server Error. Check the server logs for more details.');
} }
}); });
/** /**
* HTTP POST handler function to delete a git repository based on the extension name provided in the request body. * HTTP POST handler function to delete a git repository based on the extension name provided in the request body.
* *
* @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property. * @param {Object} request - HTTP Request object, expects a JSON body with a 'extensionName' property.
* @param {Object} response - HTTP Response object used to respond to the HTTP request. * @param {Object} response - HTTP Response object used to respond to the HTTP request.
* *
* @returns {void} * @returns {void}
*/ */
router.post('/delete', async (request, response) => { router.post('/delete', async (request, response) => {
if (!request.body.extensionName) {
return response.status(400).send('Bad Request: extensionName is required in the request body.');
}
try { try {
if (typeof request.body.extensionName !== 'string') {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
const { extensionName, global } = request.body; const { extensionName, global } = request.body;
const extensionNameSanitized = sanitize(extensionName);
if (!extensionNameSanitized) {
return response.status(400).send('Bad Request: A valid extensionName is required in the request body.');
}
if (global && !request.user.profile.admin) { if (global && !request.user.profile.admin) {
console.error(`User ${request.user.profile.handle} does not have permission to delete global extensions.`); 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) => {
} }
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
const extensionPath = path.join(basePath, sanitize(extensionName)); const extensionPath = path.join(basePath, extensionNameSanitized);
if (!fs.existsSync(extensionPath)) { if (!fs.existsSync(extensionPath)) {
return response.status(404).send(`Directory does not exist at ${extensionPath}`); return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -410,8 +454,8 @@ router.post('/delete', async (request, response) => {
return response.send(`Extension has been deleted at ${extensionPath}`); return response.send(`Extension has been deleted at ${extensionPath}`);
} catch (error) { } catch (error) {
console.error('Deleting custom content failed', error); console.error('Deleting extension failed', error);
return response.status(500).send(`Server Error: ${error.message}`); return response.status(500).send('Internal Server Error. Check the server logs for more details.');
} }
}); });