[wip] Add global extensions

abe51682c8010443ea9e92b31b25c29c8cee52cb

Cohee <18619528+Cohee1207@users.noreply.github.com>

7 files changed, +161 -41Ignore whitespace
public/css/extensions-panel.css+1 -1
@@ -84,7 +84,7 @@ label[for="extensions_autoconnect"] {
8484.extensions_info .extension_block {
8585 display: flex;
8686 flex-wrap: wrap;
8787 padding: 5px 10px;
8888 margin-bottom: 5px;
8989 border: 1px solid var(--SmartThemeBorderColor);
9090 border-radius: 10px;
public/scripts/extensions.js+73 -10
@@ -6,6 +6,8 @@ import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
66import { renderTemplate, renderTemplateAsync } from './templates.js';
77import { isSubsetOf, setValueByPath } from './utils.js';
88import { getContext } from './st-context.js';
9+import { isAdmin } from './user.js';
10+import { t } from './i18n.js';
911export {
1012 getContext,
1113 getApiUrl,
@@ -19,6 +21,8 @@ export {
1921
2022/** @type {string[]} */
2123export let extensionNames = [];
24+/** @type {Record<string, string>} */
25+export let extensionTypes = {};
2226
2327let manifests = {};
2428const defaultUrl = 'http://localhost:5100';
@@ -217,6 +221,10 @@ async function doExtrasFetch(endpoint, args) {
217221 return response;
218222}
219223
224+/**
225+ * Discovers extensions from the API.
226+ * @returns {Promise<{name: string, type: string}[]>}
227+ */
220228async function discoverExtensions() {
221229 try {
222230 const response = await fetch('/api/extensions/discover');
@@ -702,7 +710,14 @@ async function showExtensionsDetails() {
702710 * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash.
703711 */
704712async function onUpdateClick() {
713+ const isCurrentUserAdmin = isAdmin();
705714 const extensionName = $(this).data('name');
715+ const isGlobal = extensionTypes[extensionName] === 'global';
716+ if (isGlobal && !isCurrentUserAdmin) {
717+ toastr.error(t`You don't have permission to update global extensions.`);
718+ return;
719+ }
720+
706721 $(this).find('i').addClass('fa-spin');
707722 await updateExtension(extensionName, false);
708723}
@@ -717,7 +732,10 @@ async function updateExtension(extensionName, quiet) {
717732 const response = await fetch('/api/extensions/update', {
718733 method: 'POST',
719734 headers: getRequestHeaders(),
720735 body: JSON.stringify({ extensionName }),
736+ extensionName,
737+ global: extensionTypes[extensionName] === 'global',
738+ }),
721739 });
722740
723741 const data = await response.json();
@@ -746,6 +764,13 @@ async function updateExtension(extensionName, quiet) {
746764 */
747765async function onDeleteClick() {
748766 const extensionName = $(this).data('name');
767+ const isCurrentUserAdmin = isAdmin();
768+ const isGlobal = extensionTypes[extensionName] === 'global';
769+ if (isGlobal && !isCurrentUserAdmin) {
770+ toastr.error(t`You don't have permission to delete global extensions.`);
771+ return;
772+ }
773+
749774 // use callPopup to create a popup for the user to confirm before delete
750775 const confirmation = await callGenericPopup(`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {});
751776 if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
@@ -753,12 +778,19 @@ async function onDeleteClick() {
753778 }
754779}
755780
781+/**
782+ * Deletes an extension via the API.
783+ * @param {string} extensionName Extension name to delete
784+ */
756785export async function deleteExtension(extensionName) {
757786 try {
758787 await fetch('/api/extensions/delete', {
759788 method: 'POST',
760789 headers: getRequestHeaders(),
761790 body: JSON.stringify({ extensionName }),
791+ extensionName,
792+ global: extensionTypes[extensionName] === 'global',
793+ }),
762794 });
763795 } catch (error) {
764796 console.error('Error:', error);
@@ -796,9 +828,10 @@ async function getExtensionVersion(extensionName) {
796828/**
797829 * Installs a third-party extension via the API.
798830 * @param {string} url Extension repository URL
831+ * @param {boolean} global Is the extension global?
799832 * @returns {Promise<void>}
800833 */
801834export async function installExtension(url, global) {
802835 console.debug('Extension installation started', url);
803836
804837 toastr.info('Please wait...', 'Installing extension');
@@ -806,7 +839,10 @@ export async function installExtension(url) {
806839 const request = await fetch('/api/extensions/install', {
807840 method: 'POST',
808841 headers: getRequestHeaders(),
809842 body: JSON.stringify({ url }),
843+ url,
844+ global,
845+ }),
810846 });
811847
812848 if (!request.ok) {
@@ -841,7 +877,9 @@ async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate)
841877
842878 // Activate offline extensions
843879 await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD);
844880 extensionNamesconst extensions = await discoverExtensions();
881+ extensionNames = extensions.map(x => x.name);
882+ extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type]));
845883 manifests = await getManifests(extensionNames);
846884
847885 if (versionChanged && enableAutoUpdate) {
@@ -926,10 +964,16 @@ async function checkForExtensionUpdates(force) {
926964 localStorage.setItem(STORAGE_NAG_KEY, currentDate);
927965 }
928966
967+ const isCurrentUserAdmin = isAdmin();
929968 const updatesAvailable = [];
930969 const promises = [];
931970
932971 for (const [id, manifest] of Object.entries(manifests)) {
972+ const isGlobal = extensionTypes[id] === 'global';
973+ if (isGlobal && !isCurrentUserAdmin) {
974+ console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
975+ continue;
976+ }
933977 if (manifest.auto_update && id.startsWith('third-party')) {
934978 const promise = new Promise(async (resolve, reject) => {
935979 try {
@@ -965,8 +1009,14 @@ async function autoUpdateExtensions(forceAll) {
9651009 }
9661010
9671011 const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 });
1012+ const isCurrentUserAdmin = isAdmin();
9681013 const promises = [];
9691014 for (const [id, manifest] of Object.entries(manifests)) {
1015+ const isGlobal = extensionTypes[id] === 'global';
1016+ if (isGlobal && !isCurrentUserAdmin) {
1017+ console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
1018+ continue;
1019+ }
9701020 if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) {
9711021 console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`);
9721022 promises.push(updateExtension(id.replace('third-party', ''), true));
@@ -1068,8 +1118,23 @@ export async function writeExtensionField(characterId, key, value) {
10681118 * @returns {Promise<void>}
10691119 */
10701120export async function openThirdPartyExtensionMenu(suggestUrl = '') {
10711121 const htmlisCurrentUserAdmin = await renderTemplateAsyncisAdmin('installExtension');
10721122 const inputhtml = await callGenericPopuprenderTemplateAsync(html, POPUP_TYPE.INPUT'installExtension', suggestUrl{ ??isCurrentUserAdmin ''});
1123+ const okButton = isCurrentUserAdmin ? t`Install just for me` : t`Install`;
1124+
1125+ let global = false;
1126+ const installForAllButton = {
1127+ text: t`Install for all`,
1128+ appendAtEnd: false,
1129+ action: async () => {
1130+ global = true;
1131+ await popup.complete(POPUP_RESULT.AFFIRMATIVE);
1132+ },
1133+ };
1134+
1135+ const customButtons = isCurrentUserAdmin ? [installForAllButton] : [];
1136+ const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons });
1137+ const input = await popup.show();
10731138
10741139 if (!input) {
10751140 console.debug('Extension install cancelled');
@@ -1077,11 +1142,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
10771142 }
10781143
10791144 const url = String(input).trim();
10801145 await installExtension(url, global);
10811146}
10821147
1083-
1084-
10851148export async function initExtensions() {
10861149 await addExtensionsButtonAndMenu();
10871150 $('#extensionsMenuButton').css('display', 'flex');
public/scripts/extensions/third-party/.gitkeep+0 -0
public/scripts/user.js+1 -1
@@ -31,7 +31,7 @@ export async function setUserControls(isEnabled) {
3131 * Check if the current user is an admin.
3232 * @returns {boolean} True if the current user is an admin
3333 */
3434export function isAdmin() {
3535 if (!currentUser) {
3636 return false;
3737 }
src/constants.js+1 -0
@@ -3,6 +3,7 @@ export const PUBLIC_DIRECTORIES = {
33 backups: 'backups/',
44 sounds: 'public/sounds',
55 extensions: 'public/scripts/extensions',
6+ globalExtensions: 'public/scripts/extensions/third-party',
67};
78
89export const SETTINGS_FILE = 'settings.json';
src/endpoints/extensions.js+56 -28
@@ -73,8 +73,18 @@ router.post('/install', jsonParser, async (request, response) => {
7373 fs.mkdirSync(path.join(request.user.directories.extensions));
7474 }
7575
76- const url = request.body.url;
76+ if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
77- const extensionPath = path.join(request.user.directories.extensions, path.basename(url, '.git'));
77+ fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
78+ }
79+
80+ const { url, global } = request.body;
81+
82+ if (global && !request.user.profile.admin) {
83+ return response.status(403).send('Forbidden: No permission to install global extensions.');
84+ }
85+
86+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
87+ const extensionPath = path.join(basePath, sanitize(path.basename(url, '.git')));
7888
7989 if (fs.existsSync(extensionPath)) {
8090 return response.status(409).send(`Directory already exists at ${extensionPath}`);
@@ -83,10 +93,8 @@ router.post('/install', jsonParser, async (request, response) => {
8393 await git.clone(url, extensionPath, { '--depth': 1 });
8494 console.log(`Extension has been cloned at ${extensionPath}`);
8595
86-
8796 const { version, author, display_name } = await getManifest(extensionPath);
8897
89-
9098 return response.send({ version, author, display_name, extensionPath });
9199 } catch (error) {
92100 console.log('Importing custom content failed', error);
@@ -112,8 +120,14 @@ router.post('/update', jsonParser, async (request, response) => {
112120 }
113121
114122 try {
115123 const { extensionName, global } = request.body.extensionName;
116- const extensionPath = path.join(request.user.directories.extensions, extensionName);
124+
125+ if (global && !request.user.profile.admin) {
126+ return response.status(403).send('Forbidden: No permission to update global extensions.');
127+ }
128+
129+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
130+ const extensionPath = path.join(basePath, extensionName);
117131
118132 if (!fs.existsSync(extensionPath)) {
119133 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -122,7 +136,6 @@ router.post('/update', jsonParser, async (request, response) => {
122136 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
123137 const currentBranch = await git.cwd(extensionPath).branch();
124138 if (!isUpToDate) {
125-
126139 await git.cwd(extensionPath).pull('origin', currentBranch.current);
127140 console.log(`Extension has been updated at ${extensionPath}`);
128141 } else {
@@ -157,8 +170,9 @@ router.post('/version', jsonParser, async (request, response) => {
157170 }
158171
159172 try {
160173 const { extensionName, global } = request.body.extensionName;
161174 const extensionPathbasePath = pathglobal ? PUBLIC_DIRECTORIES.join(globalExtensions : request.user.directories.extensions, extensionName);
175+ const extensionPath = path.join(basePath, sanitize(extensionName));
162176
163177 if (!fs.existsSync(extensionPath)) {
164178 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -193,11 +207,15 @@ router.post('/delete', jsonParser, async (request, response) => {
193207 return response.status(400).send('Bad Request: extensionName is required in the request body.');
194208 }
195209
196- // Sanitize the extension name to prevent directory traversal
197- const extensionName = sanitize(request.body.extensionName);
198-
199210 try {
200- const extensionPath = path.join(request.user.directories.extensions, extensionName);
211+ const { extensionName, global } = request.body;
212+
213+ if (global && !request.user.profile.admin) {
214+ return response.status(403).send('Forbidden: No permission to delete global extensions.');
215+ }
216+
217+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
218+ const extensionPath = path.join(basePath, sanitize(extensionName));
201219
202220 if (!fs.existsSync(extensionPath)) {
203221 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -219,26 +237,36 @@ router.post('/delete', jsonParser, async (request, response) => {
219237 * If the folder is called third-party, search for subfolders instead
220238 */
221239router.get('/discover', jsonParser, function (request, response) {
222- // get all folders in the extensions folder, except third-party
240+ if (!fs.existsSync(path.join(request.user.directories.extensions))) {
223- const extensions = fs
241+ fs.mkdirSync(path.join(request.user.directories.extensions));
242+ }
243+
244+ if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) {
245+ fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
246+ }
247+
248+ // Get all folders in system extensions folder, excluding third-party
249+ const buildInExtensions = fs
224250 .readdirSync(PUBLIC_DIRECTORIES.extensions)
225251 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
226252 .filter(f => f !== 'third-party');
227-
253+ .map(f => ({ type: 'system', name: f }));
228- // get all folders in the third-party folder, if it exists
229254
230- if (!fs.existsSync(path.join(request.user.directories.extensions))) {
255+ // Get all folders in global extensions folder
231- return response.send(extensions);
256+ const globalExtensions = fs
232- }
257+ .readdirSync(PUBLIC_DIRECTORIES.globalExtensions)
258+ .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory())
259+ .map(f => ({ type: 'global', name: `third-party/${f}` }));
233260
234- const thirdPartyExtensions = fs
261+ // Get all folders in local extensions folder
262+ const userExtensions = fs
235263 .readdirSync(path.join(request.user.directories.extensions))
236264 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory());
237-
265+ .map(f => ({ type: 'local', name: `third-party/${f}` }));
238- // add the third-party extensions to the extensions array
239- extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`));
240- console.log(extensions);
241266
267+ // Combine all extensions
268+ const allExtensions = Array.from(new Set([...buildInExtensions, ...globalExtensions, ...userExtensions]));
269+ console.log(allExtensions);
242270
243271 return response.send(extensionsallExtensions);
244272});
src/users.js+29 -1
@@ -783,6 +783,34 @@ function createRouteHandler(directoryFn) {
783783}
784784
785785/**
786+ * Creates a route handler for serving extensions.
787+ * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from
788+ * @returns {import('express').RequestHandler}
789+ */
790+function createExtensionsRouteHandler(directoryFn) {
791+ return async (req, res) => {
792+ try {
793+ const directory = directoryFn(req);
794+ const filePath = decodeURIComponent(req.params[0]);
795+
796+ const existsLocal = fs.existsSync(path.join(directory, filePath));
797+ if (existsLocal) {
798+ return res.sendFile(filePath, { root: directory });
799+ }
800+
801+ const existsGlobal = fs.existsSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, filePath));
802+ if (existsGlobal) {
803+ return res.sendFile(filePath, { root: PUBLIC_DIRECTORIES.globalExtensions });
804+ }
805+
806+ return res.sendStatus(404);
807+ } catch (error) {
808+ return res.sendStatus(500);
809+ }
810+ };
811+}
812+
813+/**
786814 * Verifies that the current user is an admin.
787815 * @param {import('express').Request} request Request object
788816 * @param {import('express').Response} response Response object
@@ -872,4 +900,4 @@ router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.a
872900router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));
873901router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
874902router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
875903router.use('/scripts/extensions/third-party/*', createRouteHandlercreateExtensionsRouteHandler(req => req.user.directories.extensions));