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:
@@ -97,13 +97,23 @@ label[for="extensions_autoconnect"] {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.extensions_info .extension_version {
|
||||
.extensions_info :is(.extension_version, .extension_author) {
|
||||
opacity: 0.8;
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
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 {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
}
|
||||
@@ -161,4 +171,4 @@ input.extension_missing[type="checkbox"] {
|
||||
z-index: 1;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7953,6 +7953,10 @@ export async function getSettings(initLoaderHandle = null) {
|
||||
const isVersionChanged = settings.currentVersion !== currentVersion;
|
||||
await loadExtensionSettings(settings, isVersionChanged, enableAutoUpdate);
|
||||
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;
|
||||
|
||||
@@ -61,6 +61,19 @@ let manifests = {};
|
||||
*/
|
||||
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 stateChanged = false;
|
||||
let saveMetadataTimeout = null;
|
||||
@@ -928,6 +941,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
|
||||
${originHtml}
|
||||
<span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
|
||||
<span class="extension_name">${DOMPurify.sanitize(displayName)}</span>
|
||||
<span class="extension_author"></span>
|
||||
<span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>
|
||||
${modulesInfo}
|
||||
</span>
|
||||
@@ -1557,9 +1571,53 @@ async function switchExtensionBranch(extensionName, isGlobal, branch) {
|
||||
* Installs a third-party extension via the API.
|
||||
* @param {string} url Extension repository URL
|
||||
* @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 = '') {
|
||||
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);
|
||||
|
||||
toastr.info(t`Please wait...`, t`Installing extension`);
|
||||
@@ -1578,11 +1636,11 @@ export async function installExtension(url, global, branch = '') {
|
||||
const text = await request.text();
|
||||
toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 });
|
||||
console.error('Extension installation failed', request.status, request.statusText, text);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
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}`);
|
||||
await loadExtensionSettings({}, false, false);
|
||||
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}`;
|
||||
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');
|
||||
if (versionElement) {
|
||||
versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`;
|
||||
@@ -2057,6 +2129,39 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
|
||||
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() {
|
||||
await addExtensionsButtonAndMenu();
|
||||
$('#extensionsMenuButton').css('display', 'flex');
|
||||
|
||||
@@ -5,7 +5,7 @@ TODO:
|
||||
|
||||
import { DOMPurify } from '../../../lib.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 { executeSlashCommandsWithOptions } from '../../slash-commands.js';
|
||||
import { accountStorage } from '../../util/AccountStorage.js';
|
||||
@@ -60,35 +60,6 @@ const KNOWN_TYPES = {
|
||||
'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) {
|
||||
updateCurrentAssets().then(async function () {
|
||||
fetch(url, { cache: 'no-cache' })
|
||||
@@ -153,7 +124,15 @@ async function downloadAssetsList(url) {
|
||||
element.off('click');
|
||||
label.removeClass('fa-download');
|
||||
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');
|
||||
this.classList.remove('asset-download-button-loading');
|
||||
element.on('click', assetDelete);
|
||||
@@ -248,7 +227,14 @@ async function downloadAssetsList(url) {
|
||||
|
||||
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.on('click', 'a.asset_preview', previewAsset);
|
||||
@@ -322,9 +308,9 @@ async function installAsset(url, assetType, filename) {
|
||||
try {
|
||||
if (category === 'extension') {
|
||||
console.debug(DEBUG_PREFIX, 'Installing extension ', url);
|
||||
await installExtension(url, false);
|
||||
const result = await installExtension(url, false);
|
||||
console.debug(DEBUG_PREFIX, 'Extension installed.');
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
const body = { url, category, filename };
|
||||
@@ -343,10 +329,12 @@ async function installAsset(url, assetType, filename) {
|
||||
await processDroppedFiles([file]);
|
||||
console.debug(DEBUG_PREFIX, 'Character downloaded.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,9 +386,13 @@ async function openCharacterBrowser(forceDefault) {
|
||||
|
||||
downloadButton.toggle(!isInstalled).on('click', async () => {
|
||||
downloadButton.toggleClass('fa-download fa-spinner fa-spin');
|
||||
await installAsset(character.url, 'character', character.id);
|
||||
downloadButton.hide();
|
||||
checkMark.show();
|
||||
const result = await installAsset(character.url, 'character', character.id);
|
||||
if (result) {
|
||||
downloadButton.hide();
|
||||
checkMark.show();
|
||||
} else {
|
||||
downloadButton.toggleClass('fa-download fa-spinner fa-spin');
|
||||
}
|
||||
});
|
||||
|
||||
checkMark.toggle(isInstalled);
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
<div class="assets-list-git">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -27,15 +27,20 @@
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.assets-list-div h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.assets-list-div h3 {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.assets-list-div i a {
|
||||
.assets-list-div .asset-block a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.assets-list-div>i {
|
||||
.assets-list-div .asset-block {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -46,7 +51,7 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -198,3 +203,11 @@
|
||||
.asset-name>b {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -718,6 +718,10 @@ export class Popup {
|
||||
}
|
||||
}
|
||||
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (applyAutoFocus) {
|
||||
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
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@ import sanitize from 'sanitize-filename';
|
||||
import { CheckRepoActions, default as simpleGit } from 'simple-git';
|
||||
|
||||
import { PUBLIC_DIRECTORIES } from '../constants.js';
|
||||
import { getConfigValue } from '../util.js';
|
||||
import { getConfigValue, isValidUrl } from '../util.js';
|
||||
import { createGitClient } from '../git/client.js';
|
||||
|
||||
const gitBackend = getConfigValue('git.backend', 'auto');
|
||||
@@ -65,6 +65,15 @@ async function checkIfRepoIsUpToDate(extensionPath) {
|
||||
|
||||
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,
|
||||
* and return extension information and path.
|
||||
@@ -75,11 +84,23 @@ export const router = express.Router();
|
||||
* @returns {void}
|
||||
*/
|
||||
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 {
|
||||
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 });
|
||||
|
||||
// make sure the third-party directory exists
|
||||
@@ -91,15 +112,13 @@ router.post('/install', async (request, response) => {
|
||||
fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
|
||||
}
|
||||
|
||||
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.');
|
||||
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
||||
const extensionNameSanitized = sanitize(path.basename(parsedUrl.pathname, '.git'));
|
||||
if (!extensionNameSanitized) {
|
||||
return response.status(400).send('Could not determine the extension name from the URL. Please provide a valid git repository URL.');
|
||||
}
|
||||
|
||||
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
||||
const extensionPath = path.join(basePath, sanitize(path.basename(url, '.git')));
|
||||
const extensionPath = path.join(basePath, extensionNameSanitized);
|
||||
|
||||
if (fs.existsSync(extensionPath)) {
|
||||
return response.status(409).send(`Directory already exists at ${extensionPath}`);
|
||||
@@ -109,16 +128,16 @@ router.post('/install', async (request, response) => {
|
||||
if (branch) {
|
||||
cloneOptions.branch = branch;
|
||||
}
|
||||
await git.clone(url, extensionPath, cloneOptions);
|
||||
console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);
|
||||
await git.clone(parsedUrl.href, extensionPath, cloneOptions);
|
||||
console.info(`Extension has been cloned to ${extensionPath} from ${parsedUrl.href} at ${branch || '(default)'} branch`);
|
||||
|
||||
const { version, author, display_name } = await getManifest(extensionPath);
|
||||
const folderName = path.basename(extensionPath);
|
||||
|
||||
return response.send({ version, author, display_name, extensionPath, folderName });
|
||||
} catch (error) {
|
||||
console.error('Importing custom content failed', error);
|
||||
return response.status(500).send(`Server Error: ${error.message}`);
|
||||
console.error('Importing extension failed', error);
|
||||
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}
|
||||
*/
|
||||
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 {
|
||||
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 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) {
|
||||
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 extensionPath = path.join(basePath, sanitize(extensionName));
|
||||
const extensionPath = path.join(basePath, extensionNameSanitized);
|
||||
|
||||
if (!fs.existsSync(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) => {
|
||||
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) {
|
||||
return response.status(400).send('Bad Request: extensionName is required in the 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) {
|
||||
@@ -191,7 +218,7 @@ router.post('/branches', async (request, response) => {
|
||||
}
|
||||
|
||||
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)) {
|
||||
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) => {
|
||||
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) {
|
||||
return response.status(400).send('Bad Request: extensionName and branch are required in the request body.');
|
||||
const { extensionName, branch, global } = 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) {
|
||||
@@ -236,7 +267,7 @@ router.post('/switch', async (request, response) => {
|
||||
}
|
||||
|
||||
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)) {
|
||||
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) => {
|
||||
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) {
|
||||
return response.status(400).send('Bad Request. Not all required parameters are provided.');
|
||||
const { extensionName, source, destination } = request.body;
|
||||
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) {
|
||||
@@ -296,8 +331,8 @@ router.post('/move', async (request, response) => {
|
||||
|
||||
const sourceDirectory = source === '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 destinationPath = path.join(destinationDirectory, sanitize(extensionName));
|
||||
const sourcePath = path.join(sourceDirectory, extensionNameSanitized);
|
||||
const destinationPath = path.join(destinationDirectory, extensionNameSanitized);
|
||||
|
||||
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
|
||||
console.error(`Source directory does not exist at ${sourcePath}`);
|
||||
@@ -336,14 +371,19 @@ router.post('/move', async (request, response) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
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 {
|
||||
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 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 extensionPath = path.join(basePath, sanitize(extensionName));
|
||||
const extensionPath = path.join(basePath, extensionNameSanitized);
|
||||
|
||||
if (!fs.existsSync(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
|
||||
const currentBranchName = currentBranch.current;
|
||||
await git.fetch('origin');
|
||||
console.debug(extensionName, currentBranchName, currentCommitHash);
|
||||
console.debug(extensionNameSanitized, currentBranchName, currentCommitHash);
|
||||
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
|
||||
|
||||
return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl });
|
||||
} catch (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.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
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 {
|
||||
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 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) {
|
||||
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 extensionPath = path.join(basePath, sanitize(extensionName));
|
||||
const extensionPath = path.join(basePath, extensionNameSanitized);
|
||||
|
||||
if (!fs.existsSync(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}`);
|
||||
} catch (error) {
|
||||
console.error('Deleting custom content failed', error);
|
||||
return response.status(500).send(`Server Error: ${error.message}`);
|
||||
console.error('Deleting extension failed', error);
|
||||
return response.status(500).send('Internal Server Error. Check the server logs for more details.');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user