mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-02-21 14:40:48 +01:00
[wip] Add global extensions
This commit is contained in:
parent
bea991b665
commit
abe51682c8
@ -84,7 +84,7 @@ label[for="extensions_autoconnect"] {
|
||||
.extensions_info .extension_block {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 5px;
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 10px;
|
||||
|
@ -6,6 +6,8 @@ import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
|
||||
import { renderTemplate, renderTemplateAsync } from './templates.js';
|
||||
import { isSubsetOf, setValueByPath } from './utils.js';
|
||||
import { getContext } from './st-context.js';
|
||||
import { isAdmin } from './user.js';
|
||||
import { t } from './i18n.js';
|
||||
export {
|
||||
getContext,
|
||||
getApiUrl,
|
||||
@ -19,6 +21,8 @@ export {
|
||||
|
||||
/** @type {string[]} */
|
||||
export let extensionNames = [];
|
||||
/** @type {Record<string, string>} */
|
||||
export let extensionTypes = {};
|
||||
|
||||
let manifests = {};
|
||||
const defaultUrl = 'http://localhost:5100';
|
||||
@ -217,6 +221,10 @@ async function doExtrasFetch(endpoint, args) {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers extensions from the API.
|
||||
* @returns {Promise<{name: string, type: string}[]>}
|
||||
*/
|
||||
async function discoverExtensions() {
|
||||
try {
|
||||
const response = await fetch('/api/extensions/discover');
|
||||
@ -702,7 +710,14 @@ async function showExtensionsDetails() {
|
||||
* If the extension is not up to date, it updates the extension and displays a success message with the new commit hash.
|
||||
*/
|
||||
async function onUpdateClick() {
|
||||
const isCurrentUserAdmin = isAdmin();
|
||||
const extensionName = $(this).data('name');
|
||||
const isGlobal = extensionTypes[extensionName] === 'global';
|
||||
if (isGlobal && !isCurrentUserAdmin) {
|
||||
toastr.error(t`You don't have permission to update global extensions.`);
|
||||
return;
|
||||
}
|
||||
|
||||
$(this).find('i').addClass('fa-spin');
|
||||
await updateExtension(extensionName, false);
|
||||
}
|
||||
@ -717,7 +732,10 @@ async function updateExtension(extensionName, quiet) {
|
||||
const response = await fetch('/api/extensions/update', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ extensionName }),
|
||||
body: JSON.stringify({
|
||||
extensionName,
|
||||
global: extensionTypes[extensionName] === 'global',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@ -746,6 +764,13 @@ async function updateExtension(extensionName, quiet) {
|
||||
*/
|
||||
async function onDeleteClick() {
|
||||
const extensionName = $(this).data('name');
|
||||
const isCurrentUserAdmin = isAdmin();
|
||||
const isGlobal = extensionTypes[extensionName] === 'global';
|
||||
if (isGlobal && !isCurrentUserAdmin) {
|
||||
toastr.error(t`You don't have permission to delete global extensions.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// use callPopup to create a popup for the user to confirm before delete
|
||||
const confirmation = await callGenericPopup(`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {});
|
||||
if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
|
||||
@ -753,12 +778,19 @@ async function onDeleteClick() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an extension via the API.
|
||||
* @param {string} extensionName Extension name to delete
|
||||
*/
|
||||
export async function deleteExtension(extensionName) {
|
||||
try {
|
||||
await fetch('/api/extensions/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ extensionName }),
|
||||
body: JSON.stringify({
|
||||
extensionName,
|
||||
global: extensionTypes[extensionName] === 'global',
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
@ -796,9 +828,10 @@ async function getExtensionVersion(extensionName) {
|
||||
/**
|
||||
* Installs a third-party extension via the API.
|
||||
* @param {string} url Extension repository URL
|
||||
* @param {boolean} global Is the extension global?
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function installExtension(url) {
|
||||
export async function installExtension(url, global) {
|
||||
console.debug('Extension installation started', url);
|
||||
|
||||
toastr.info('Please wait...', 'Installing extension');
|
||||
@ -806,7 +839,10 @@ export async function installExtension(url) {
|
||||
const request = await fetch('/api/extensions/install', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ url }),
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
global,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!request.ok) {
|
||||
@ -841,7 +877,9 @@ async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate)
|
||||
|
||||
// Activate offline extensions
|
||||
await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD);
|
||||
extensionNames = await discoverExtensions();
|
||||
const extensions = await discoverExtensions();
|
||||
extensionNames = extensions.map(x => x.name);
|
||||
extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type]));
|
||||
manifests = await getManifests(extensionNames);
|
||||
|
||||
if (versionChanged && enableAutoUpdate) {
|
||||
@ -926,10 +964,16 @@ async function checkForExtensionUpdates(force) {
|
||||
localStorage.setItem(STORAGE_NAG_KEY, currentDate);
|
||||
}
|
||||
|
||||
const isCurrentUserAdmin = isAdmin();
|
||||
const updatesAvailable = [];
|
||||
const promises = [];
|
||||
|
||||
for (const [id, manifest] of Object.entries(manifests)) {
|
||||
const isGlobal = extensionTypes[id] === 'global';
|
||||
if (isGlobal && !isCurrentUserAdmin) {
|
||||
console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
|
||||
continue;
|
||||
}
|
||||
if (manifest.auto_update && id.startsWith('third-party')) {
|
||||
const promise = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
@ -965,8 +1009,14 @@ async function autoUpdateExtensions(forceAll) {
|
||||
}
|
||||
|
||||
const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 });
|
||||
const isCurrentUserAdmin = isAdmin();
|
||||
const promises = [];
|
||||
for (const [id, manifest] of Object.entries(manifests)) {
|
||||
const isGlobal = extensionTypes[id] === 'global';
|
||||
if (isGlobal && !isCurrentUserAdmin) {
|
||||
console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
|
||||
continue;
|
||||
}
|
||||
if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) {
|
||||
console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`);
|
||||
promises.push(updateExtension(id.replace('third-party', ''), true));
|
||||
@ -1068,8 +1118,23 @@ export async function writeExtensionField(characterId, key, value) {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function openThirdPartyExtensionMenu(suggestUrl = '') {
|
||||
const html = await renderTemplateAsync('installExtension');
|
||||
const input = await callGenericPopup(html, POPUP_TYPE.INPUT, suggestUrl ?? '');
|
||||
const isCurrentUserAdmin = isAdmin();
|
||||
const html = await renderTemplateAsync('installExtension', { isCurrentUserAdmin });
|
||||
const okButton = isCurrentUserAdmin ? t`Install just for me` : t`Install`;
|
||||
|
||||
let global = false;
|
||||
const installForAllButton = {
|
||||
text: t`Install for all`,
|
||||
appendAtEnd: false,
|
||||
action: async () => {
|
||||
global = true;
|
||||
await popup.complete(POPUP_RESULT.AFFIRMATIVE);
|
||||
},
|
||||
};
|
||||
|
||||
const customButtons = isCurrentUserAdmin ? [installForAllButton] : [];
|
||||
const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons });
|
||||
const input = await popup.show();
|
||||
|
||||
if (!input) {
|
||||
console.debug('Extension install cancelled');
|
||||
@ -1077,11 +1142,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
|
||||
}
|
||||
|
||||
const url = String(input).trim();
|
||||
await installExtension(url);
|
||||
await installExtension(url, global);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function initExtensions() {
|
||||
await addExtensionsButtonAndMenu();
|
||||
$('#extensionsMenuButton').css('display', 'flex');
|
||||
|
0
public/scripts/extensions/third-party/.gitkeep
vendored
Normal file
0
public/scripts/extensions/third-party/.gitkeep
vendored
Normal file
@ -31,7 +31,7 @@ export async function setUserControls(isEnabled) {
|
||||
* Check if the current user is an admin.
|
||||
* @returns {boolean} True if the current user is an admin
|
||||
*/
|
||||
function isAdmin() {
|
||||
export function isAdmin() {
|
||||
if (!currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ export const PUBLIC_DIRECTORIES = {
|
||||
backups: 'backups/',
|
||||
sounds: 'public/sounds',
|
||||
extensions: 'public/scripts/extensions',
|
||||
globalExtensions: 'public/scripts/extensions/third-party',
|
||||
};
|
||||
|
||||
export const SETTINGS_FILE = 'settings.json';
|
||||
|
@ -73,8 +73,18 @@ router.post('/install', jsonParser, async (request, response) => {
|
||||
fs.mkdirSync(path.join(request.user.directories.extensions));
|
||||
}
|
||||
|
||||
const url = request.body.url;
|
||||
const extensionPath = path.join(request.user.directories.extensions, path.basename(url, '.git'));
|
||||
if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
|
||||
fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
|
||||
}
|
||||
|
||||
const { url, global } = request.body;
|
||||
|
||||
if (global && !request.user.profile.admin) {
|
||||
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, sanitize(path.basename(url, '.git')));
|
||||
|
||||
if (fs.existsSync(extensionPath)) {
|
||||
return response.status(409).send(`Directory already exists at ${extensionPath}`);
|
||||
@ -83,10 +93,8 @@ router.post('/install', jsonParser, async (request, response) => {
|
||||
await git.clone(url, extensionPath, { '--depth': 1 });
|
||||
console.log(`Extension has been cloned at ${extensionPath}`);
|
||||
|
||||
|
||||
const { version, author, display_name } = await getManifest(extensionPath);
|
||||
|
||||
|
||||
return response.send({ version, author, display_name, extensionPath });
|
||||
} catch (error) {
|
||||
console.log('Importing custom content failed', error);
|
||||
@ -112,8 +120,14 @@ router.post('/update', jsonParser, async (request, response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const extensionName = request.body.extensionName;
|
||||
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
||||
const { extensionName, global } = request.body;
|
||||
|
||||
if (global && !request.user.profile.admin) {
|
||||
return response.status(403).send('Forbidden: No permission to update global extensions.');
|
||||
}
|
||||
|
||||
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
||||
const extensionPath = path.join(basePath, extensionName);
|
||||
|
||||
if (!fs.existsSync(extensionPath)) {
|
||||
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
||||
@ -122,7 +136,6 @@ router.post('/update', jsonParser, async (request, response) => {
|
||||
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
|
||||
const currentBranch = await git.cwd(extensionPath).branch();
|
||||
if (!isUpToDate) {
|
||||
|
||||
await git.cwd(extensionPath).pull('origin', currentBranch.current);
|
||||
console.log(`Extension has been updated at ${extensionPath}`);
|
||||
} else {
|
||||
@ -157,8 +170,9 @@ router.post('/version', jsonParser, async (request, response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const extensionName = request.body.extensionName;
|
||||
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
||||
const { extensionName, global } = request.body;
|
||||
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
||||
const extensionPath = path.join(basePath, sanitize(extensionName));
|
||||
|
||||
if (!fs.existsSync(extensionPath)) {
|
||||
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
||||
@ -193,11 +207,15 @@ router.post('/delete', jsonParser, async (request, response) => {
|
||||
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
||||
}
|
||||
|
||||
// Sanitize the extension name to prevent directory traversal
|
||||
const extensionName = sanitize(request.body.extensionName);
|
||||
|
||||
try {
|
||||
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
||||
const { extensionName, global } = request.body;
|
||||
|
||||
if (global && !request.user.profile.admin) {
|
||||
return response.status(403).send('Forbidden: No permission to delete global extensions.');
|
||||
}
|
||||
|
||||
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
||||
const extensionPath = path.join(basePath, sanitize(extensionName));
|
||||
|
||||
if (!fs.existsSync(extensionPath)) {
|
||||
return response.status(404).send(`Directory does not exist at ${extensionPath}`);
|
||||
@ -219,26 +237,36 @@ router.post('/delete', jsonParser, async (request, response) => {
|
||||
* If the folder is called third-party, search for subfolders instead
|
||||
*/
|
||||
router.get('/discover', jsonParser, function (request, response) {
|
||||
// get all folders in the extensions folder, except third-party
|
||||
const extensions = fs
|
||||
.readdirSync(PUBLIC_DIRECTORIES.extensions)
|
||||
.filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
|
||||
.filter(f => f !== 'third-party');
|
||||
|
||||
// get all folders in the third-party folder, if it exists
|
||||
|
||||
if (!fs.existsSync(path.join(request.user.directories.extensions))) {
|
||||
return response.send(extensions);
|
||||
fs.mkdirSync(path.join(request.user.directories.extensions));
|
||||
}
|
||||
|
||||
const thirdPartyExtensions = fs
|
||||
if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
|
||||
fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
|
||||
}
|
||||
|
||||
// Get all folders in system extensions folder, excluding third-party
|
||||
const buildInExtensions = fs
|
||||
.readdirSync(PUBLIC_DIRECTORIES.extensions)
|
||||
.filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
|
||||
.filter(f => f !== 'third-party')
|
||||
.map(f => ({ type: 'system', name: f }));
|
||||
|
||||
// Get all folders in global extensions folder
|
||||
const globalExtensions = fs
|
||||
.readdirSync(PUBLIC_DIRECTORIES.globalExtensions)
|
||||
.filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory())
|
||||
.map(f => ({ type: 'global', name: `third-party/${f}` }));
|
||||
|
||||
// Get all folders in local extensions folder
|
||||
const userExtensions = fs
|
||||
.readdirSync(path.join(request.user.directories.extensions))
|
||||
.filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory());
|
||||
.filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())
|
||||
.map(f => ({ type: 'local', name: `third-party/${f}` }));
|
||||
|
||||
// add the third-party extensions to the extensions array
|
||||
extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`));
|
||||
console.log(extensions);
|
||||
// Combine all extensions
|
||||
const allExtensions = Array.from(new Set([...buildInExtensions, ...globalExtensions, ...userExtensions]));
|
||||
console.log(allExtensions);
|
||||
|
||||
|
||||
return response.send(extensions);
|
||||
return response.send(allExtensions);
|
||||
});
|
||||
|
30
src/users.js
30
src/users.js
@ -782,6 +782,34 @@ function createRouteHandler(directoryFn) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a route handler for serving extensions.
|
||||
* @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
|
||||
* @returns {import('express').RequestHandler}
|
||||
*/
|
||||
function createExtensionsRouteHandler(directoryFn) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
const directory = directoryFn(req);
|
||||
const filePath = decodeURIComponent(req.params[0]);
|
||||
|
||||
const existsLocal = fs.existsSync(path.join(directory, filePath));
|
||||
if (existsLocal) {
|
||||
return res.sendFile(filePath, { root: directory });
|
||||
}
|
||||
|
||||
const existsGlobal = fs.existsSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, filePath));
|
||||
if (existsGlobal) {
|
||||
return res.sendFile(filePath, { root: PUBLIC_DIRECTORIES.globalExtensions });
|
||||
}
|
||||
|
||||
return res.sendStatus(404);
|
||||
} catch (error) {
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the current user is an admin.
|
||||
* @param {import('express').Request} request Request object
|
||||
@ -872,4 +900,4 @@ router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.a
|
||||
router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));
|
||||
router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
|
||||
router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
|
||||
router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions));
|
||||
router.use('/scripts/extensions/third-party/*', createExtensionsRouteHandler(req => req.user.directories.extensions));
|
||||
|
Loading…
x
Reference in New Issue
Block a user