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