mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-02-23 15:37:50 +01:00
Merge pull request #3145 from SillyTavern/redesign-extension-manager
Redesign extension manager
This commit is contained in:
commit
00006aa072
@ -12,3 +12,4 @@ access.log
|
|||||||
/data
|
/data
|
||||||
/cache
|
/cache
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/public/scripts/extensions/third-party
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -50,3 +50,5 @@ public/css/user.css
|
|||||||
/default/scaffold
|
/default/scaffold
|
||||||
public/scripts/extensions/third-party
|
public/scripts/extensions/third-party
|
||||||
/certs
|
/certs
|
||||||
|
.aider*
|
||||||
|
.env
|
||||||
|
@ -11,3 +11,4 @@ access.log
|
|||||||
.github
|
.github
|
||||||
.vscode
|
.vscode
|
||||||
.git
|
.git
|
||||||
|
/public/scripts/extensions/third-party
|
||||||
|
@ -65,7 +65,7 @@ label[for="extensions_autoconnect"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.extensions_info .extension_enabled {
|
.extensions_info .extension_enabled {
|
||||||
color: green;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.extensions_info .extension_disabled {
|
.extensions_info .extension_disabled {
|
||||||
@ -76,13 +76,44 @@ label[for="extensions_autoconnect"] {
|
|||||||
color: gray;
|
color: gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
input.extension_missing[type="checkbox"] {
|
.extensions_info .extension_modules {
|
||||||
opacity: 0.5;
|
font-size: 0.8em;
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
#extensions_list .disabled {
|
.extensions_info .extension_block {
|
||||||
text-decoration: line-through;
|
display: flex;
|
||||||
color: lightgray;
|
flex-wrap: nowrap;
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border: 1px solid var(--SmartThemeBorderColor);
|
||||||
|
border-radius: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensions_info .extension_name {
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensions_info .extension_version {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensions_info .extension_block a {
|
||||||
|
color: var(--SmartThemeBodyColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensions_info .extension_name.update_available {
|
||||||
|
color: limegreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.extension_missing[type="checkbox"] {
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-button {
|
.update-button {
|
||||||
@ -105,3 +136,13 @@ input.extension_missing[type="checkbox"] {
|
|||||||
#extensionsMenu>div.extension_container:empty {
|
#extensionsMenu>div.extension_container:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.extensions_info .extension_text_block {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extensions_info .extension_actions {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
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.
|
* Check if the current user is an admin.
|
||||||
* @returns {boolean} True if the current user is an admin
|
* @returns {boolean} True if the current user is an admin
|
||||||
*/
|
*/
|
||||||
function isAdmin() {
|
export function isAdmin() {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ export const PUBLIC_DIRECTORIES = {
|
|||||||
backups: 'backups/',
|
backups: 'backups/',
|
||||||
sounds: 'public/sounds',
|
sounds: 'public/sounds',
|
||||||
extensions: 'public/scripts/extensions',
|
extensions: 'public/scripts/extensions',
|
||||||
|
globalExtensions: 'public/scripts/extensions/third-party',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SETTINGS_FILE = 'settings.json';
|
export const SETTINGS_FILE = 'settings.json';
|
||||||
|
@ -73,8 +73,19 @@ router.post('/install', jsonParser, async (request, response) => {
|
|||||||
fs.mkdirSync(path.join(request.user.directories.extensions));
|
fs.mkdirSync(path.join(request.user.directories.extensions));
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = request.body.url;
|
if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
|
||||||
const extensionPath = path.join(request.user.directories.extensions, path.basename(url, '.git'));
|
fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, global } = request.body;
|
||||||
|
|
||||||
|
if (global && !request.user.profile.admin) {
|
||||||
|
console.warn(`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 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}`);
|
||||||
@ -83,10 +94,8 @@ router.post('/install', jsonParser, async (request, response) => {
|
|||||||
await git.clone(url, extensionPath, { '--depth': 1 });
|
await git.clone(url, extensionPath, { '--depth': 1 });
|
||||||
console.log(`Extension has been cloned at ${extensionPath}`);
|
console.log(`Extension has been cloned at ${extensionPath}`);
|
||||||
|
|
||||||
|
|
||||||
const { version, author, display_name } = await getManifest(extensionPath);
|
const { version, author, display_name } = await getManifest(extensionPath);
|
||||||
|
|
||||||
|
|
||||||
return response.send({ version, author, display_name, extensionPath });
|
return response.send({ version, author, display_name, extensionPath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Importing custom content failed', error);
|
console.log('Importing custom content failed', error);
|
||||||
@ -112,8 +121,15 @@ router.post('/update', jsonParser, async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extensionName = request.body.extensionName;
|
const { extensionName, global } = request.body;
|
||||||
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
|
||||||
|
if (global && !request.user.profile.admin) {
|
||||||
|
console.warn(`User ${request.user.profile.handle} does not have permission to update global extensions.`);
|
||||||
|
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)) {
|
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}`);
|
||||||
@ -122,7 +138,6 @@ router.post('/update', jsonParser, async (request, response) => {
|
|||||||
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
|
const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
|
||||||
const currentBranch = await git.cwd(extensionPath).branch();
|
const currentBranch = await git.cwd(extensionPath).branch();
|
||||||
if (!isUpToDate) {
|
if (!isUpToDate) {
|
||||||
|
|
||||||
await git.cwd(extensionPath).pull('origin', currentBranch.current);
|
await git.cwd(extensionPath).pull('origin', currentBranch.current);
|
||||||
console.log(`Extension has been updated at ${extensionPath}`);
|
console.log(`Extension has been updated at ${extensionPath}`);
|
||||||
} else {
|
} else {
|
||||||
@ -140,6 +155,50 @@ router.post('/update', jsonParser, async (request, response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/move', jsonParser, async (request, response) => {
|
||||||
|
try {
|
||||||
|
const { extensionName, source, destination } = request.body;
|
||||||
|
|
||||||
|
if (!extensionName || !source || !destination) {
|
||||||
|
return response.status(400).send('Bad Request. Not all required parameters are provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.user.profile.admin) {
|
||||||
|
console.warn(`User ${request.user.profile.handle} does not have permission to move extensions.`);
|
||||||
|
return response.status(403).send('Forbidden: No permission to move extensions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
|
||||||
|
console.error(`Source directory does not exist at ${sourcePath}`);
|
||||||
|
return response.status(404).send('Source directory does not exist.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(destinationPath)) {
|
||||||
|
console.error(`Destination directory already exists at ${destinationPath}`);
|
||||||
|
return response.status(409).send('Destination directory already exists.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === destination) {
|
||||||
|
console.error('Source and destination directories are the same');
|
||||||
|
return response.status(409).send('Source and destination directories are the same.');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true });
|
||||||
|
fs.rmSync(sourcePath, { recursive: true, force: true });
|
||||||
|
console.log(`Extension has been moved from ${sourcePath} to ${destinationPath}`);
|
||||||
|
|
||||||
|
return response.sendStatus(204);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Moving extension failed', error);
|
||||||
|
return response.status(500).send('Internal Server Error. Try again later.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP POST handler function to get the current git commit hash and branch name for a given extension.
|
* HTTP POST handler function to get the current git commit hash and branch name for a given extension.
|
||||||
* It checks whether the repository is up-to-date with the remote, and returns the status along with
|
* It checks whether the repository is up-to-date with the remote, and returns the status along with
|
||||||
@ -157,19 +216,28 @@ router.post('/version', jsonParser, async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const extensionName = request.body.extensionName;
|
const { extensionName, global } = request.body;
|
||||||
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
|
||||||
|
const extensionPath = path.join(basePath, sanitize(extensionName));
|
||||||
|
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentCommitHash;
|
||||||
|
try {
|
||||||
|
currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
|
||||||
|
} catch (error) {
|
||||||
|
// it is not a git repo, or has no commits yet, or is a bare repo
|
||||||
|
// not possible to update it, most likely can't get the branch name either
|
||||||
|
return response.send({ currentBranchName: null, currentCommitHash, isUpToDate: true, remoteUrl: null });
|
||||||
|
}
|
||||||
|
|
||||||
const currentBranch = await git.cwd(extensionPath).branch();
|
const currentBranch = await git.cwd(extensionPath).branch();
|
||||||
// get only the working branch
|
// get only the working branch
|
||||||
const currentBranchName = currentBranch.current;
|
const currentBranchName = currentBranch.current;
|
||||||
await git.cwd(extensionPath).fetch('origin');
|
await git.cwd(extensionPath).fetch('origin');
|
||||||
const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
|
console.log(extensionName, currentBranchName, currentCommitHash);
|
||||||
console.log(currentBranch, 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 });
|
||||||
@ -193,11 +261,16 @@ router.post('/delete', jsonParser, async (request, response) => {
|
|||||||
return response.status(400).send('Bad Request: extensionName is required in the request body.');
|
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 {
|
try {
|
||||||
const extensionPath = path.join(request.user.directories.extensions, extensionName);
|
const { extensionName, global } = request.body;
|
||||||
|
|
||||||
|
if (global && !request.user.profile.admin) {
|
||||||
|
console.warn(`User ${request.user.profile.handle} does not have permission to delete global extensions.`);
|
||||||
|
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)) {
|
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}`);
|
||||||
@ -219,26 +292,38 @@ router.post('/delete', jsonParser, async (request, response) => {
|
|||||||
* If the folder is called third-party, search for subfolders instead
|
* If the folder is called third-party, search for subfolders instead
|
||||||
*/
|
*/
|
||||||
router.get('/discover', jsonParser, function (request, response) {
|
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))) {
|
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 builtInExtensions = 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 local extensions folder
|
||||||
|
const userExtensions = fs
|
||||||
.readdirSync(path.join(request.user.directories.extensions))
|
.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
|
// Get all folders in global extensions folder
|
||||||
extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`));
|
// In case of a conflict, the extension will be loaded from the user folder
|
||||||
console.log(extensions);
|
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}` }))
|
||||||
|
.filter(f => !userExtensions.some(e => e.name === f.name));
|
||||||
|
|
||||||
|
// Combine all extensions
|
||||||
|
const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions];
|
||||||
|
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.
|
* Verifies that the current user is an admin.
|
||||||
* @param {import('express').Request} request Request object
|
* @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('/assets/*', createRouteHandler(req => req.user.directories.assets));
|
||||||
router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
|
router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
|
||||||
router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
|
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