[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"] {
84.extensions_info .extension_block {84.extensions_info .extension_block {
85 display: flex;85 display: flex;
86 flex-wrap: wrap;86 flex-wrap: wrap;
87 padding: 10px;87 padding: 5px 10px;
88 margin-bottom: 5px;88 margin-bottom: 5px;
89 border: 1px solid var(--SmartThemeBorderColor);89 border: 1px solid var(--SmartThemeBorderColor);
90 border-radius: 10px;90 border-radius: 10px;
public/scripts/extensions.js+73 -10
@@ -6,6 +6,8 @@ import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
6import { renderTemplate, renderTemplateAsync } from './templates.js';6import { renderTemplate, renderTemplateAsync } from './templates.js';
7import { isSubsetOf, setValueByPath } from './utils.js';7import { isSubsetOf, setValueByPath } from './utils.js';
8import { getContext } from './st-context.js';8import { getContext } from './st-context.js';
9import { isAdmin } from './user.js';
10import { t } from './i18n.js';
9export {11export {
10 getContext,12 getContext,
11 getApiUrl,13 getApiUrl,
@@ -19,6 +21,8 @@ export {
1921
20/** @type {string[]} */22/** @type {string[]} */
21export let extensionNames = [];23export let extensionNames = [];
24/** @type {Record<string, string>} */
25export let extensionTypes = {};
2226
23let manifests = {};27let manifests = {};
24const defaultUrl = 'http://localhost:5100';28const defaultUrl = 'http://localhost:5100';
@@ -217,6 +221,10 @@ async function doExtrasFetch(endpoint, args) {
217 return response;221 return response;
218}222}
219223
224/**
225 * Discovers extensions from the API.
226 * @returns {Promise<{name: string, type: string}[]>}
227 */
220async function discoverExtensions() {228async function discoverExtensions() {
221 try {229 try {
222 const response = await fetch('/api/extensions/discover');230 const response = await fetch('/api/extensions/discover');
@@ -702,7 +710,14 @@ async function showExtensionsDetails() {
702 * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash.710 * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash.
703 */711 */
704async function onUpdateClick() {712async function onUpdateClick() {
713 const isCurrentUserAdmin = isAdmin();
705 const extensionName = $(this).data('name');714 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
706 $(this).find('i').addClass('fa-spin');721 $(this).find('i').addClass('fa-spin');
707 await updateExtension(extensionName, false);722 await updateExtension(extensionName, false);
708}723}
@@ -717,7 +732,10 @@ async function updateExtension(extensionName, quiet) {
717 const response = await fetch('/api/extensions/update', {732 const response = await fetch('/api/extensions/update', {
718 method: 'POST',733 method: 'POST',
719 headers: getRequestHeaders(),734 headers: getRequestHeaders(),
720 body: JSON.stringify({ extensionName }),735 body: JSON.stringify({
736 extensionName,
737 global: extensionTypes[extensionName] === 'global',
738 }),
721 });739 });
722740
723 const data = await response.json();741 const data = await response.json();
@@ -746,6 +764,13 @@ async function updateExtension(extensionName, quiet) {
746 */764 */
747async function onDeleteClick() {765async function onDeleteClick() {
748 const extensionName = $(this).data('name');766 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
749 // use callPopup to create a popup for the user to confirm before delete774 // use callPopup to create a popup for the user to confirm before delete
750 const confirmation = await callGenericPopup(`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {});775 const confirmation = await callGenericPopup(`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {});
751 if (confirmation === POPUP_RESULT.AFFIRMATIVE) {776 if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
@@ -753,12 +778,19 @@ async function onDeleteClick() {
753 }778 }
754}779}
755780
781/**
782 * Deletes an extension via the API.
783 * @param {string} extensionName Extension name to delete
784 */
756export async function deleteExtension(extensionName) {785export async function deleteExtension(extensionName) {
757 try {786 try {
758 await fetch('/api/extensions/delete', {787 await fetch('/api/extensions/delete', {
759 method: 'POST',788 method: 'POST',
760 headers: getRequestHeaders(),789 headers: getRequestHeaders(),
761 body: JSON.stringify({ extensionName }),790 body: JSON.stringify({
791 extensionName,
792 global: extensionTypes[extensionName] === 'global',
793 }),
762 });794 });
763 } catch (error) {795 } catch (error) {
764 console.error('Error:', error);796 console.error('Error:', error);
@@ -796,9 +828,10 @@ async function getExtensionVersion(extensionName) {
796/**828/**
797 * Installs a third-party extension via the API.829 * Installs a third-party extension via the API.
798 * @param {string} url Extension repository URL830 * @param {string} url Extension repository URL
831 * @param {boolean} global Is the extension global?
799 * @returns {Promise<void>}832 * @returns {Promise<void>}
800 */833 */
801export async function installExtension(url) {834export async function installExtension(url, global) {
802 console.debug('Extension installation started', url);835 console.debug('Extension installation started', url);
803836
804 toastr.info('Please wait...', 'Installing extension');837 toastr.info('Please wait...', 'Installing extension');
@@ -806,7 +839,10 @@ export async function installExtension(url) {
806 const request = await fetch('/api/extensions/install', {839 const request = await fetch('/api/extensions/install', {
807 method: 'POST',840 method: 'POST',
808 headers: getRequestHeaders(),841 headers: getRequestHeaders(),
809 body: JSON.stringify({ url }),842 body: JSON.stringify({
843 url,
844 global,
845 }),
810 });846 });
811847
812 if (!request.ok) {848 if (!request.ok) {
@@ -841,7 +877,9 @@ async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate)
841877
842 // Activate offline extensions878 // Activate offline extensions
843 await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD);879 await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD);
844 extensionNames = await discoverExtensions();880 const extensions = await discoverExtensions();
881 extensionNames = extensions.map(x => x.name);
882 extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type]));
845 manifests = await getManifests(extensionNames);883 manifests = await getManifests(extensionNames);
846884
847 if (versionChanged && enableAutoUpdate) {885 if (versionChanged && enableAutoUpdate) {
@@ -926,10 +964,16 @@ async function checkForExtensionUpdates(force) {
926 localStorage.setItem(STORAGE_NAG_KEY, currentDate);964 localStorage.setItem(STORAGE_NAG_KEY, currentDate);
927 }965 }
928966
967 const isCurrentUserAdmin = isAdmin();
929 const updatesAvailable = [];968 const updatesAvailable = [];
930 const promises = [];969 const promises = [];
931970
932 for (const [id, manifest] of Object.entries(manifests)) {971 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 }
933 if (manifest.auto_update && id.startsWith('third-party')) {977 if (manifest.auto_update && id.startsWith('third-party')) {
934 const promise = new Promise(async (resolve, reject) => {978 const promise = new Promise(async (resolve, reject) => {
935 try {979 try {
@@ -965,8 +1009,14 @@ async function autoUpdateExtensions(forceAll) {
965 }1009 }
9661010
967 const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 });1011 const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 });
1012 const isCurrentUserAdmin = isAdmin();
968 const promises = [];1013 const promises = [];
969 for (const [id, manifest] of Object.entries(manifests)) {1014 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 }
970 if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) {1020 if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) {
971 console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`);1021 console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`);
972 promises.push(updateExtension(id.replace('third-party', ''), true));1022 promises.push(updateExtension(id.replace('third-party', ''), true));
@@ -1068,8 +1118,23 @@ export async function writeExtensionField(characterId, key, value) {
1068 * @returns {Promise<void>}1118 * @returns {Promise<void>}
1069 */1119 */
1070export async function openThirdPartyExtensionMenu(suggestUrl = '') {1120export async function openThirdPartyExtensionMenu(suggestUrl = '') {
1071 const html = await renderTemplateAsync('installExtension');1121 const isCurrentUserAdmin = isAdmin();
1072 const input = await callGenericPopup(html, POPUP_TYPE.INPUT, suggestUrl ?? '');1122 const html = await renderTemplateAsync('installExtension', { 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
1074 if (!input) {1139 if (!input) {
1075 console.debug('Extension install cancelled');1140 console.debug('Extension install cancelled');
@@ -1077,11 +1142,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
1077 }1142 }
10781143
1079 const url = String(input).trim();1144 const url = String(input).trim();
1080 await installExtension(url);1145 await installExtension(url, global);
1081}1146}
10821147
1083
1084
1085export async function initExtensions() {1148export async function initExtensions() {
1086 await addExtensionsButtonAndMenu();1149 await addExtensionsButtonAndMenu();
1087 $('#extensionsMenuButton').css('display', 'flex');1150 $('#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) {
31 * Check if the current user is an admin.31 * Check if the current user is an admin.
32 * @returns {boolean} True if the current user is an admin32 * @returns {boolean} True if the current user is an admin
33 */33 */
34function isAdmin() {34export function isAdmin() {
35 if (!currentUser) {35 if (!currentUser) {
36 return false;36 return false;
37 }37 }
src/constants.js+1 -0
@@ -3,6 +3,7 @@ export const PUBLIC_DIRECTORIES = {
3 backups: 'backups/',3 backups: 'backups/',
4 sounds: 'public/sounds',4 sounds: 'public/sounds',
5 extensions: 'public/scripts/extensions',5 extensions: 'public/scripts/extensions',
6 globalExtensions: 'public/scripts/extensions/third-party',
6};7};
78
8export const SETTINGS_FILE = 'settings.json';9export const SETTINGS_FILE = 'settings.json';
src/endpoints/extensions.js+56 -28
@@ -73,8 +73,18 @@ router.post('/install', jsonParser, async (request, response) => {
73 fs.mkdirSync(path.join(request.user.directories.extensions));73 fs.mkdirSync(path.join(request.user.directories.extensions));
74 }74 }
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
79 if (fs.existsSync(extensionPath)) {89 if (fs.existsSync(extensionPath)) {
80 return response.status(409).send(`Directory already exists at ${extensionPath}`);90 return response.status(409).send(`Directory already exists at ${extensionPath}`);
@@ -83,10 +93,8 @@ router.post('/install', jsonParser, async (request, response) => {
83 await git.clone(url, extensionPath, { '--depth': 1 });93 await git.clone(url, extensionPath, { '--depth': 1 });
84 console.log(`Extension has been cloned at ${extensionPath}`);94 console.log(`Extension has been cloned at ${extensionPath}`);
8595
86
87 const { version, author, display_name } = await getManifest(extensionPath);96 const { version, author, display_name } = await getManifest(extensionPath);
8897
89
90 return response.send({ version, author, display_name, extensionPath });98 return response.send({ version, author, display_name, extensionPath });
91 } catch (error) {99 } catch (error) {
92 console.log('Importing custom content failed', error);100 console.log('Importing custom content failed', error);
@@ -112,8 +120,14 @@ router.post('/update', jsonParser, async (request, response) => {
112 }120 }
113121
114 try {122 try {
115 const extensionName = request.body.extensionName;123 const { extensionName, global } = request.body;
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
118 if (!fs.existsSync(extensionPath)) {132 if (!fs.existsSync(extensionPath)) {
119 return response.status(404).send(`Directory does not exist at ${extensionPath}`);133 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -122,7 +136,6 @@ router.post('/update', jsonParser, async (request, response) => {
122 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);136 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
123 const currentBranch = await git.cwd(extensionPath).branch();137 const currentBranch = await git.cwd(extensionPath).branch();
124 if (!isUpToDate) {138 if (!isUpToDate) {
125
126 await git.cwd(extensionPath).pull('origin', currentBranch.current);139 await git.cwd(extensionPath).pull('origin', currentBranch.current);
127 console.log(`Extension has been updated at ${extensionPath}`);140 console.log(`Extension has been updated at ${extensionPath}`);
128 } else {141 } else {
@@ -157,8 +170,9 @@ router.post('/version', jsonParser, async (request, response) => {
157 }170 }
158171
159 try {172 try {
160 const extensionName = request.body.extensionName;173 const { extensionName, global } = request.body;
161 const extensionPath = path.join(request.user.directories.extensions, extensionName);174 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
175 const extensionPath = path.join(basePath, sanitize(extensionName));
162176
163 if (!fs.existsSync(extensionPath)) {177 if (!fs.existsSync(extensionPath)) {
164 return response.status(404).send(`Directory does not exist at ${extensionPath}`);178 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -193,11 +207,15 @@ router.post('/delete', jsonParser, async (request, response) => {
193 return response.status(400).send('Bad Request: extensionName is required in the request body.');207 return response.status(400).send('Bad Request: extensionName is required in the request body.');
194 }208 }
195209
196 // Sanitize the extension name to prevent directory traversal
197 const extensionName = sanitize(request.body.extensionName);
198
199 try {210 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
202 if (!fs.existsSync(extensionPath)) {220 if (!fs.existsSync(extensionPath)) {
203 return response.status(404).send(`Directory does not exist at ${extensionPath}`);221 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
@@ -219,26 +237,36 @@ router.post('/delete', jsonParser, async (request, response) => {
219 * If the folder is called third-party, search for subfolders instead237 * If the folder is called third-party, search for subfolders instead
220 */238 */
221router.get('/discover', jsonParser, function (request, response) {239router.get('/discover', jsonParser, function (request, response) {
222 // get all folders in the extensions folder, except third-party240 if (!fs.existsSync(path.join(request.user.directories.extensions))) {
223 const extensions = fs241 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
224 .readdirSync(PUBLIC_DIRECTORIES.extensions)250 .readdirSync(PUBLIC_DIRECTORIES.extensions)
225 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())251 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
226 .filter(f => f !== 'third-party');252 .filter(f => f !== 'third-party')
227253 .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 = fs261 // Get all folders in local extensions folder
262 const userExtensions = fs
235 .readdirSync(path.join(request.user.directories.extensions))263 .readdirSync(path.join(request.user.directories.extensions))
236 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory());264 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())
237265 .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
243 return response.send(extensions);271 return response.send(allExtensions);
244});272});
src/users.js+29 -1
@@ -783,6 +783,34 @@ function createRouteHandler(directoryFn) {
783}783}
784784
785/**785/**
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 */
790function 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/**
786 * Verifies that the current user is an admin.814 * Verifies that the current user is an admin.
787 * @param {import('express').Request} request Request object815 * @param {import('express').Request} request Request object
788 * @param {import('express').Response} response Response object816 * @param {import('express').Response} response Response object
@@ -872,4 +900,4 @@ router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.a
872router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));900router.use('/assets/*', createRouteHandler(req => req.user.directories.assets));
873router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));901router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages));
874router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));902router.use('/user/files/*', createRouteHandler(req => req.user.directories.files));
875router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions));903router.use('/scripts/extensions/third-party/*', createExtensionsRouteHandler(req => req.user.directories.extensions));