import path from 'node:path'; import fs from 'node:fs'; import express from 'express'; import sanitize from 'sanitize-filename'; import { default as simpleGit } from 'simple-git'; import { PUBLIC_DIRECTORIES } from '../constants.js'; /** * This function extracts the extension information from the manifest file. * @param {string} extensionPath - The path of the extension folder * @returns {Promise} - Returns the manifest data as an object */ async function getManifest(extensionPath) { const manifestPath = path.join(extensionPath, 'manifest.json'); // Check if manifest.json exists if (!fs.existsSync(manifestPath)) { throw new Error(`Manifest file not found at ${manifestPath}`); } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); return manifest; } /** * This function checks if the local repository is up-to-date with the remote repository. * @param {string} extensionPath - The path of the extension folder * @returns {Promise} - Returns the extension information as an object */ async function checkIfRepoIsUpToDate(extensionPath) { const git = simpleGit(); await git.cwd(extensionPath).fetch('origin'); const currentBranch = await git.cwd(extensionPath).branch(); const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); const log = await git.cwd(extensionPath).log({ from: currentCommitHash, to: `origin/${currentBranch.current}`, }); // Fetch remote repository information const remotes = await git.cwd(extensionPath).getRemotes(true); return { isUpToDate: log.total === 0, remoteUrl: remotes[0].refs.fetch, // URL of the remote repository }; } export const router = express.Router(); /** * HTTP POST handler function to clone a git repository from a provided URL, read the extension manifest, * and return extension information and path. * * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property. * @param {Object} response - HTTP Response object used to respond to the HTTP request. * * @returns {void} */ router.post('/install', async (request, response) => { if (!request.body.url) { return response.status(400).send('Bad Request: URL is required in the request body.'); } try { const git = simpleGit(); // make sure the third-party directory exists if (!fs.existsSync(path.join(request.user.directories.extensions))) { fs.mkdirSync(path.join(request.user.directories.extensions)); } if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) { fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions); } const { url, global } = request.body; if (global && !request.user.profile.admin) { console.error(`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)) { return response.status(409).send(`Directory already exists at ${extensionPath}`); } await git.clone(url, extensionPath, { '--depth': 1 }); console.info(`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.error('Importing custom content failed', error); return response.status(500).send(`Server Error: ${error.message}`); } }); /** * HTTP POST handler function to pull the latest updates from a git repository * based on the extension name provided in the request body. It returns the latest commit hash, * the path of the extension, the status of the repository (whether it's up-to-date or not), * and the remote URL of the repository. * * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property. * @param {Object} response - HTTP Response object used to respond to the HTTP request. * * @returns {void} */ router.post('/update', async (request, response) => { const git = simpleGit(); if (!request.body.extensionName) { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } try { const { extensionName, global } = request.body; if (global && !request.user.profile.admin) { console.error(`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)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); } const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); const currentBranch = await git.cwd(extensionPath).branch(); if (!isUpToDate) { await git.cwd(extensionPath).pull('origin', currentBranch.current); console.info(`Extension has been updated at ${extensionPath}`); } else { console.info(`Extension is up to date at ${extensionPath}`); } await git.cwd(extensionPath).fetch('origin'); const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); const shortCommitHash = fullCommitHash.slice(0, 7); return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl }); } catch (error) { console.error('Updating custom content failed', error); return response.status(500).send(`Server Error: ${error.message}`); } }); router.post('/move', 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.error(`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.info(`Extension has been moved from ${sourcePath} to ${destinationPath}`); return response.sendStatus(204); } catch (error) { console.error('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. * It checks whether the repository is up-to-date with the remote, and returns the status along with * the remote URL of the repository. * * @param {Object} request - HTTP Request object, expects a JSON body with an 'extensionName' property. * @param {Object} response - HTTP Response object used to respond to the HTTP request. * * @returns {void} */ router.post('/version', async (request, response) => { const git = simpleGit(); if (!request.body.extensionName) { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } try { 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}`); } 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: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' }); } const currentBranch = await git.cwd(extensionPath).branch(); // get only the working branch const currentBranchName = currentBranch.current; await git.cwd(extensionPath).fetch('origin'); console.debug(extensionName, currentBranchName, currentCommitHash); const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl }); } catch (error) { console.error('Getting extension version failed', error); return response.status(500).send(`Server Error: ${error.message}`); } }); /** * HTTP POST handler function to delete a git repository based on the extension name provided in the request body. * * @param {Object} request - HTTP Request object, expects a JSON body with a 'url' property. * @param {Object} response - HTTP Response object used to respond to the HTTP request. * * @returns {void} */ router.post('/delete', async (request, response) => { if (!request.body.extensionName) { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } try { const { extensionName, global } = request.body; if (global && !request.user.profile.admin) { console.error(`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)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); } await fs.promises.rm(extensionPath, { recursive: true }); console.info(`Extension has been deleted at ${extensionPath}`); return response.send(`Extension has been deleted at ${extensionPath}`); } catch (error) { console.error('Deleting custom content failed', error); return response.status(500).send(`Server Error: ${error.message}`); } }); /** * Discover the extension folders * If the folder is called third-party, search for subfolders instead */ router.get('/discover', function (request, response) { if (!fs.existsSync(path.join(request.user.directories.extensions))) { fs.mkdirSync(path.join(request.user.directories.extensions)); } 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)) .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory()) .map(f => ({ type: 'local', name: `third-party/${f}` })); // Get all folders in global extensions folder // In case of a conflict, the extension will be loaded from the user 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}` })) .filter(f => !userExtensions.some(e => e.name === f.name)); // Combine all extensions const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions]; console.debug('Extensions available for', request.user.profile.handle, allExtensions); return response.send(allExtensions); });