From 6e562bd1ffe78f9efe8c352d1b7adcfb7e10ddf4 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 16 Sep 2023 16:16:48 +0300 Subject: [PATCH] Extract server endpoints for thumbnails and extensions into separate files --- public/script.js | 4 +- public/scripts/extensions.js | 8 +- server.js | 425 +---------------------------------- src/constants.js | 30 +++ src/extensions.js | 254 +++++++++++++++++++++ src/thumbnails.js | 201 +++++++++++++++++ 6 files changed, 499 insertions(+), 423 deletions(-) create mode 100644 src/constants.js create mode 100644 src/extensions.js create mode 100644 src/thumbnails.js diff --git a/public/script.js b/public/script.js index 30577a2ef..7ec03ebaa 100644 --- a/public/script.js +++ b/public/script.js @@ -8747,7 +8747,7 @@ jQuery(async function () { /** * Handles the click event for the third-party extension import button. * Prompts the user to enter the Git URL of the extension to import. - * After obtaining the Git URL, makes a POST request to '/get_extension' to import the extension. + * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension. * If the extension is imported successfully, a success message is displayed. * If the extension import fails, an error message is displayed and the error is logged to the console. * After successfully importing the extension, the extension settings are reloaded and a 'EXTENSION_SETTINGS_LOADED' event is emitted. @@ -8770,7 +8770,7 @@ jQuery(async function () { const url = input.trim(); console.debug('Extension import started', url); - const request = await fetch('/get_extension', { + const request = await fetch('/api/extensions/install', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ url }), diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index 69e59e69d..b584f6383 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -206,7 +206,7 @@ async function doExtrasFetch(endpoint, args) { async function discoverExtensions() { try { - const response = await fetch('/discover_extensions'); + const response = await fetch('/api/extensions/discover'); if (response.ok) { const extensions = await response.json(); @@ -631,7 +631,7 @@ async function onUpdateClick() { /** * Handles the click event for the delete button of an extension. - * This function makes a POST request to '/delete_extension' with the extension's name. + * This function makes a POST request to '/api/extensions/delete' with the extension's name. * If the extension is deleted, it displays a success message. * Creates a popup for the user to confirm before delete. */ @@ -641,7 +641,7 @@ async function onDeleteClick() { const confirmation = await callPopup(`Are you sure you want to delete ${extensionName}?`, 'delete_extension'); if (confirmation) { try { - const response = await fetch('/delete_extension', { + const response = await fetch('/api/extensions/delete', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ extensionName }) @@ -668,7 +668,7 @@ async function onDeleteClick() { */ async function getExtensionVersion(extensionName) { try { - const response = await fetch('/get_extension_version', { + const response = await fetch('/api/extensions/version', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ extensionName }) diff --git a/server.js b/server.js index 832601f9d..f2b71c223 100644 --- a/server.js +++ b/server.js @@ -15,7 +15,6 @@ const { TextDecoder } = require('util'); // cli/fs related library imports const open = require('open'); const sanitize = require('sanitize-filename'); -const simpleGit = require('simple-git'); const writeFileAtomicSync = require('write-file-atomic').sync; const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); @@ -65,6 +64,7 @@ const contentManager = require('./src/content-manager'); const statsHelpers = require('./statsHelpers.js'); const { writeSecret, readSecret, readSecretState, migrateSecrets, SECRET_KEYS, getAllSecrets } = require('./src/secrets'); const { delay, getVersion, getImageBuffers } = require('./src/util'); +const { invalidateThumbnail, ensureThumbnailCache } = require('./src/thumbnails'); // Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed in v20.3.0. // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870 @@ -321,32 +321,7 @@ const AVATAR_WIDTH = 400; const AVATAR_HEIGHT = 600; const jsonParser = express.json({ limit: '100mb' }); const urlencodedParser = express.urlencoded({ extended: true, limit: '100mb' }); -const directories = { - worlds: 'public/worlds/', - avatars: 'public/User Avatars', - images: 'public/img/', - userImages: 'public/user/images/', - groups: 'public/groups/', - groupChats: 'public/group chats', - chats: 'public/chats/', - characters: 'public/characters/', - backgrounds: 'public/backgrounds', - novelAI_Settings: 'public/NovelAI Settings', - koboldAI_Settings: 'public/KoboldAI Settings', - openAI_Settings: 'public/OpenAI Settings', - textGen_Settings: 'public/TextGen Settings', - thumbnails: 'thumbnails/', - thumbnailsBg: 'thumbnails/bg/', - thumbnailsAvatar: 'thumbnails/avatar/', - themes: 'public/themes', - movingUI: 'public/movingUI', - extensions: 'public/scripts/extensions', - instruct: 'public/instruct', - context: 'public/context', - backups: 'backups/', - quickreplies: 'public/QuickReplies', - assets: 'public/assets', -}; +const { directories } = require('./src/constants'); // CSRF Protection // if (cliArguments.disableCsrf === false) { @@ -2728,36 +2703,6 @@ app.post('/deletegroup', jsonParser, async (request, response) => { return response.send({ ok: true }); }); -/** - * Discover the extension folders - * If the folder is called third-party, search for subfolders instead - */ -app.get('/discover_extensions', jsonParser, function (_, response) { - - // get all folders in the extensions folder, except third-party - const extensions = fs - .readdirSync(directories.extensions) - .filter(f => fs.statSync(path.join(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(directories.extensions, 'third-party'))) { - return response.send(extensions); - } - - const thirdPartyExtensions = fs - .readdirSync(path.join(directories.extensions, 'third-party')) - .filter(f => fs.statSync(path.join(directories.extensions, 'third-party', f)).isDirectory()); - - // add the third-party extensions to the extensions array - extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`)); - console.log(extensions); - - - return response.send(extensions); -}); - /** * Gets the path to the sprites folder for the provided character name * @param {string} name - The name of the character @@ -2816,47 +2761,6 @@ app.get('/get_sprites', jsonParser, function (request, response) { } }); -function getThumbnailFolder(type) { - let thumbnailFolder; - - switch (type) { - case 'bg': - thumbnailFolder = directories.thumbnailsBg; - break; - case 'avatar': - thumbnailFolder = directories.thumbnailsAvatar; - break; - } - - return thumbnailFolder; -} - -function getOriginalFolder(type) { - let originalFolder; - - switch (type) { - case 'bg': - originalFolder = directories.backgrounds; - break; - case 'avatar': - originalFolder = directories.characters; - break; - } - - return originalFolder; -} - -function invalidateThumbnail(type, file) { - const folder = getThumbnailFolder(type); - if (folder === undefined) throw new Error("Invalid thumbnail type") - - const pathToThumbnail = path.join(folder, file); - - if (fs.existsSync(pathToThumbnail)) { - fs.rmSync(pathToThumbnail); - } -} - function cleanUploads() { try { if (fs.existsSync(UPLOADS_PATH)) { @@ -2877,118 +2781,6 @@ function cleanUploads() { } } -async function ensureThumbnailCache() { - const cacheFiles = fs.readdirSync(directories.thumbnailsBg); - - // files exist, all ok - if (cacheFiles.length) { - return; - } - - console.log('Generating thumbnails cache. Please wait...'); - - const bgFiles = fs.readdirSync(directories.backgrounds); - const tasks = []; - - for (const file of bgFiles) { - tasks.push(generateThumbnail('bg', file)); - } - - await Promise.all(tasks); - console.log(`Done! Generated: ${bgFiles.length} preview images`); -} - -async function generateThumbnail(type, file) { - let thumbnailFolder = getThumbnailFolder(type) - let originalFolder = getOriginalFolder(type) - if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error("Invalid thumbnail type") - - const pathToCachedFile = path.join(thumbnailFolder, file); - const pathToOriginalFile = path.join(originalFolder, file); - - const cachedFileExists = fs.existsSync(pathToCachedFile); - const originalFileExists = fs.existsSync(pathToOriginalFile); - - // to handle cases when original image was updated after thumb creation - let shouldRegenerate = false; - - if (cachedFileExists && originalFileExists) { - const originalStat = fs.statSync(pathToOriginalFile); - const cachedStat = fs.statSync(pathToCachedFile); - - if (originalStat.mtimeMs > cachedStat.ctimeMs) { - //console.log('Original file changed. Regenerating thumbnail...'); - shouldRegenerate = true; - } - } - - if (cachedFileExists && !shouldRegenerate) { - return pathToCachedFile; - } - - if (!originalFileExists) { - return null; - } - - const imageSizes = { 'bg': [160, 90], 'avatar': [96, 144] }; - const mySize = imageSizes[type]; - - try { - let buffer; - - try { - const image = await jimp.read(pathToOriginalFile); - buffer = await image.cover(mySize[0], mySize[1]).quality(95).getBufferAsync('image/jpeg'); - } - catch (inner) { - console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`); - buffer = fs.readFileSync(pathToOriginalFile); - } - - writeFileAtomicSync(pathToCachedFile, buffer); - } - catch (outer) { - return null; - } - - return pathToCachedFile; -} - -app.get('/thumbnail', jsonParser, async function (request, response) { - if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') return response.sendStatus(400); - - const type = request.query.type; - const file = sanitize(request.query.file); - - if (!type || !file) { - return response.sendStatus(400); - } - - if (!(type == 'bg' || type == 'avatar')) { - return response.sendStatus(400); - } - - if (sanitize(file) !== file) { - console.error('Malicious filename prevented'); - return response.sendStatus(403); - } - - if (config.disableThumbnails == true) { - let folder = getOriginalFolder(type); - if (folder === undefined) return response.sendStatus(400); - const pathToOriginalFile = path.join(folder, file); - return response.sendFile(pathToOriginalFile, { root: process.cwd() }); - } - - const pathToCachedFile = await generateThumbnail(type, file); - - if (!pathToCachedFile) { - return response.sendStatus(404); - } - - return response.sendFile(pathToCachedFile, { root: process.cwd() }); -}); - /* OpenAI */ app.post("/getstatus_openai", jsonParser, async function (request, response_getstatus_openai) { if (!request.body) return response_getstatus_openai.sendStatus(400); @@ -4342,213 +4134,6 @@ function importRisuSprites(data) { } } -/** - * 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; -} - -async function checkIfRepoIsUpToDate(extensionPath) { - // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature - 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 - }; -} - - - -/** - * 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} - */ -app.post('/get_extension', jsonParser, async (request, response) => { - // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature - const git = simpleGit(); - if (!request.body.url) { - return response.status(400).send('Bad Request: URL is required in the request body.'); - } - - try { - // make sure the third-party directory exists - if (!fs.existsSync(directories.extensions + '/third-party')) { - fs.mkdirSync(directories.extensions + '/third-party'); - } - - const url = request.body.url; - const extensionPath = path.join(directories.extensions, 'third-party', path.basename(url, '.git')); - - if (fs.existsSync(extensionPath)) { - return response.status(409).send(`Directory already exists at ${extensionPath}`); - } - - await git.clone(url, extensionPath); - console.log(`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.log('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} - */ -app.post('/update_extension', jsonParser, async (request, response) => { - // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature - const git = simpleGit(); - if (!request.body.extensionName) { - return response.status(400).send('Bad Request: extensionName is required in the request body.'); - } - - try { - const extensionName = request.body.extensionName; - const extensionPath = path.join(directories.extensions, 'third-party', 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.log(`Extension has been updated at ${extensionPath}`); - } else { - console.log(`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.log('Updating custom content failed', error); - return response.status(500).send(`Server Error: ${error.message}`); - } -}); - -/** - * 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} - */ -app.post('/get_extension_version', jsonParser, async (request, response) => { - // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature - const git = simpleGit(); - if (!request.body.extensionName) { - return response.status(400).send('Bad Request: extensionName is required in the request body.'); - } - - try { - const extensionName = request.body.extensionName; - const extensionPath = path.join(directories.extensions, 'third-party', extensionName); - - if (!fs.existsSync(extensionPath)) { - return response.status(404).send(`Directory does not exist at ${extensionPath}`); - } - - const currentBranch = await git.cwd(extensionPath).branch(); - // get only the working branch - const currentBranchName = currentBranch.current; - await git.cwd(extensionPath).fetch('origin'); - const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); - console.log(currentBranch, currentCommitHash); - const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); - - return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl }); - - } catch (error) { - console.log('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} - */ -app.post('/delete_extension', jsonParser, async (request, response) => { - if (!request.body.extensionName) { - return response.status(400).send('Bad Request: extensionName is required in the request body.'); - } - - // Sanatize the extension name to prevent directory traversal - const extensionName = sanitize(request.body.extensionName); - - try { - const extensionPath = path.join(directories.extensions, 'third-party', extensionName); - - if (!fs.existsSync(extensionPath)) { - return response.status(404).send(`Directory does not exist at ${extensionPath}`); - } - - await fs.promises.rmdir(extensionPath, { recursive: true }); - console.log(`Extension has been deleted at ${extensionPath}`); - - return response.send(`Extension has been deleted at ${extensionPath}`); - - } catch (error) { - console.log('Deleting custom content failed', error); - return response.status(500).send(`Server Error: ${error.message}`); - } -}); - - /** * HTTP POST handler function to retrieve name of all files of a given folder path. * @@ -4781,9 +4366,15 @@ app.post('/get_character_assets_list', jsonParser, async (request, response) => } }); +// Thumbnail generation +require('./src/thumbnails').registerEndpoints(app, jsonParser); + // NovelAI generation require('./src/novelai').registerEndpoints(app, jsonParser); +// Third-party extensions +require('./src/extensions').registerEndpoints(app, jsonParser); + // Stable Diffusion generation require('./src/stable-diffusion').registerEndpoints(app, jsonParser); diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 000000000..51e7879b5 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,30 @@ +const directories = { + worlds: 'public/worlds/', + avatars: 'public/User Avatars', + images: 'public/img/', + userImages: 'public/user/images/', + groups: 'public/groups/', + groupChats: 'public/group chats', + chats: 'public/chats/', + characters: 'public/characters/', + backgrounds: 'public/backgrounds', + novelAI_Settings: 'public/NovelAI Settings', + koboldAI_Settings: 'public/KoboldAI Settings', + openAI_Settings: 'public/OpenAI Settings', + textGen_Settings: 'public/TextGen Settings', + thumbnails: 'thumbnails/', + thumbnailsBg: 'thumbnails/bg/', + thumbnailsAvatar: 'thumbnails/avatar/', + themes: 'public/themes', + movingUI: 'public/movingUI', + extensions: 'public/scripts/extensions', + instruct: 'public/instruct', + context: 'public/context', + backups: 'backups/', + quickreplies: 'public/QuickReplies', + assets: 'public/assets', +}; + +module.exports = { + directories, +} diff --git a/src/extensions.js b/src/extensions.js new file mode 100644 index 000000000..ed231bb0b --- /dev/null +++ b/src/extensions.js @@ -0,0 +1,254 @@ +const path = require('path'); +const fs = require('fs'); +const simpleGit = require('simple-git'); +const sanitize = require('sanitize-filename'); +const { directories } = require('./constants'); + +/** + * 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) { + // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature + 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 + }; +} + +/** + * Registers the endpoints for the third-party extensions API. + * @param {import('express').Express} app - Express app + * @param {any} jsonParser - JSON parser middleware + */ +function registerEndpoints(app, jsonParser) { + /** + * 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} + */ + app.post('/api/extensions/install', jsonParser, async (request, response) => { + // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature + const git = simpleGit(); + if (!request.body.url) { + return response.status(400).send('Bad Request: URL is required in the request body.'); + } + + try { + // make sure the third-party directory exists + if (!fs.existsSync(path.join(directories.extensions, 'third-party'))) { + fs.mkdirSync(path.join(directories.extensions, 'third-party')); + } + + const url = request.body.url; + const extensionPath = path.join(directories.extensions, 'third-party', path.basename(url, '.git')); + + if (fs.existsSync(extensionPath)) { + return response.status(409).send(`Directory already exists at ${extensionPath}`); + } + + await git.clone(url, extensionPath); + console.log(`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.log('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} + */ + app.post('/api/extensions/update', jsonParser, async (request, response) => { + // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature + const git = simpleGit(); + if (!request.body.extensionName) { + return response.status(400).send('Bad Request: extensionName is required in the request body.'); + } + + try { + const extensionName = request.body.extensionName; + const extensionPath = path.join(directories.extensions, 'third-party', 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.log(`Extension has been updated at ${extensionPath}`); + } else { + console.log(`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.log('Updating custom content failed', error); + return response.status(500).send(`Server Error: ${error.message}`); + } + }); + + /** + * 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} + */ + app.post('/api/extensions/version', jsonParser, async (request, response) => { + // @ts-ignore - simple-git types are incorrect, this is apparently callable but no call signature + const git = simpleGit(); + if (!request.body.extensionName) { + return response.status(400).send('Bad Request: extensionName is required in the request body.'); + } + + try { + const extensionName = request.body.extensionName; + const extensionPath = path.join(directories.extensions, 'third-party', extensionName); + + if (!fs.existsSync(extensionPath)) { + return response.status(404).send(`Directory does not exist at ${extensionPath}`); + } + + const currentBranch = await git.cwd(extensionPath).branch(); + // get only the working branch + const currentBranchName = currentBranch.current; + await git.cwd(extensionPath).fetch('origin'); + const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); + console.log(currentBranch, currentCommitHash); + const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); + + return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl }); + + } catch (error) { + console.log('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} + */ + app.post('/api/extensions/delete', jsonParser, async (request, response) => { + if (!request.body.extensionName) { + return response.status(400).send('Bad Request: extensionName is required in the request body.'); + } + + // Sanatize the extension name to prevent directory traversal + const extensionName = sanitize(request.body.extensionName); + + try { + const extensionPath = path.join(directories.extensions, 'third-party', extensionName); + + if (!fs.existsSync(extensionPath)) { + return response.status(404).send(`Directory does not exist at ${extensionPath}`); + } + + await fs.promises.rmdir(extensionPath, { recursive: true }); + console.log(`Extension has been deleted at ${extensionPath}`); + + return response.send(`Extension has been deleted at ${extensionPath}`); + + } catch (error) { + console.log('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 + */ + app.get('/api/extensions/discover', jsonParser, function (_, response) { + + // get all folders in the extensions folder, except third-party + const extensions = fs + .readdirSync(directories.extensions) + .filter(f => fs.statSync(path.join(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(directories.extensions, 'third-party'))) { + return response.send(extensions); + } + + const thirdPartyExtensions = fs + .readdirSync(path.join(directories.extensions, 'third-party')) + .filter(f => fs.statSync(path.join(directories.extensions, 'third-party', f)).isDirectory()); + + // add the third-party extensions to the extensions array + extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`)); + console.log(extensions); + + + return response.send(extensions); + }); +} + +module.exports = { + registerEndpoints, +} diff --git a/src/thumbnails.js b/src/thumbnails.js new file mode 100644 index 000000000..fd93e87d6 --- /dev/null +++ b/src/thumbnails.js @@ -0,0 +1,201 @@ +const fs = require('fs'); +const path = require('path'); +const sanitize = require('sanitize-filename'); +const jimp = require('jimp'); +const writeFileAtomicSync = require('write-file-atomic').sync; +const { directories } = require('./constants'); +const { getConfigValue } = require('./util'); + +/** + * Gets a path to thumbnail folder based on the type. + * @param {'bg' | 'avatar'} type Thumbnail type + * @returns {string} Path to the thumbnails folder + */ +function getThumbnailFolder(type) { + let thumbnailFolder; + + switch (type) { + case 'bg': + thumbnailFolder = directories.thumbnailsBg; + break; + case 'avatar': + thumbnailFolder = directories.thumbnailsAvatar; + break; + } + + return thumbnailFolder; +} + +/** + * Gets a path to the original images folder based on the type. + * @param {'bg' | 'avatar'} type Thumbnail type + * @returns {string} Path to the original images folder + */ +function getOriginalFolder(type) { + let originalFolder; + + switch (type) { + case 'bg': + originalFolder = directories.backgrounds; + break; + case 'avatar': + originalFolder = directories.characters; + break; + } + + return originalFolder; +} + +/** + * Removes the generated thumbnail from the disk. + * @param {'bg' | 'avatar'} type Type of the thumbnail + * @param {string} file Name of the file + */ +function invalidateThumbnail(type, file) { + const folder = getThumbnailFolder(type); + if (folder === undefined) throw new Error("Invalid thumbnail type") + + const pathToThumbnail = path.join(folder, file); + + if (fs.existsSync(pathToThumbnail)) { + fs.rmSync(pathToThumbnail); + } +} + +/** + * Generates a thumbnail for the given file. + * @param {'bg' | 'avatar'} type Type of the thumbnail + * @param {string} file Name of the file + * @returns + */ +async function generateThumbnail(type, file) { + let thumbnailFolder = getThumbnailFolder(type) + let originalFolder = getOriginalFolder(type) + if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error("Invalid thumbnail type") + + const pathToCachedFile = path.join(thumbnailFolder, file); + const pathToOriginalFile = path.join(originalFolder, file); + + const cachedFileExists = fs.existsSync(pathToCachedFile); + const originalFileExists = fs.existsSync(pathToOriginalFile); + + // to handle cases when original image was updated after thumb creation + let shouldRegenerate = false; + + if (cachedFileExists && originalFileExists) { + const originalStat = fs.statSync(pathToOriginalFile); + const cachedStat = fs.statSync(pathToCachedFile); + + if (originalStat.mtimeMs > cachedStat.ctimeMs) { + //console.log('Original file changed. Regenerating thumbnail...'); + shouldRegenerate = true; + } + } + + if (cachedFileExists && !shouldRegenerate) { + return pathToCachedFile; + } + + if (!originalFileExists) { + return null; + } + + const imageSizes = { 'bg': [160, 90], 'avatar': [96, 144] }; + const mySize = imageSizes[type]; + + try { + let buffer; + + try { + const image = await jimp.read(pathToOriginalFile); + buffer = await image.cover(mySize[0], mySize[1]).quality(95).getBufferAsync('image/jpeg'); + } + catch (inner) { + console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`); + buffer = fs.readFileSync(pathToOriginalFile); + } + + writeFileAtomicSync(pathToCachedFile, buffer); + } + catch (outer) { + return null; + } + + return pathToCachedFile; +} + +/** + * Ensures that the thumbnail cache for backgrounds is valid. + * @returns {Promise} Promise that resolves when the cache is validated + */ +async function ensureThumbnailCache() { + const cacheFiles = fs.readdirSync(directories.thumbnailsBg); + + // files exist, all ok + if (cacheFiles.length) { + return; + } + + console.log('Generating thumbnails cache. Please wait...'); + + const bgFiles = fs.readdirSync(directories.backgrounds); + const tasks = []; + + for (const file of bgFiles) { + tasks.push(generateThumbnail('bg', file)); + } + + await Promise.all(tasks); + console.log(`Done! Generated: ${bgFiles.length} preview images`); +} + + +/** + * Registers the endpoints for the thumbnail management. + * @param {import('express').Express} app Express app + * @param {any} jsonParser JSON parser middleware + */ +function registerEndpoints(app, jsonParser) { + // Important: Do not change a path to this endpoint. It is used in the client code and saved to chat files. + app.get('/thumbnail', jsonParser, async function (request, response) { + if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') return response.sendStatus(400); + + const type = request.query.type; + const file = sanitize(request.query.file); + + if (!type || !file) { + return response.sendStatus(400); + } + + if (!(type == 'bg' || type == 'avatar')) { + return response.sendStatus(400); + } + + if (sanitize(file) !== file) { + console.error('Malicious filename prevented'); + return response.sendStatus(403); + } + + if (getConfigValue('disableThumbnails', false) == true) { + let folder = getOriginalFolder(type); + if (folder === undefined) return response.sendStatus(400); + const pathToOriginalFile = path.join(folder, file); + return response.sendFile(pathToOriginalFile, { root: process.cwd() }); + } + + const pathToCachedFile = await generateThumbnail(type, file); + + if (!pathToCachedFile) { + return response.sendStatus(404); + } + + return response.sendFile(pathToCachedFile, { root: process.cwd() }); + }); + +} + +module.exports = { + invalidateThumbnail, + registerEndpoints, + ensureThumbnailCache, +}