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;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user