Extract server endpoints for thumbnails and extensions into separate files

This commit is contained in:
Cohee 2023-09-16 16:16:48 +03:00
parent 2d774f32b2
commit 6e562bd1ff
6 changed files with 499 additions and 423 deletions

View File

@ -8747,7 +8747,7 @@ jQuery(async function () {
/** /**
* Handles the click event for the third-party extension import button. * Handles the click event for the third-party extension import button.
* Prompts the user to enter the Git URL of the extension to import. * 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 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. * 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. * 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(); const url = input.trim();
console.debug('Extension import started', url); console.debug('Extension import started', url);
const request = await fetch('/get_extension', { const request = await fetch('/api/extensions/install', {
method: 'POST', method: 'POST',
headers: getRequestHeaders(), headers: getRequestHeaders(),
body: JSON.stringify({ url }), body: JSON.stringify({ url }),

View File

@ -206,7 +206,7 @@ async function doExtrasFetch(endpoint, args) {
async function discoverExtensions() { async function discoverExtensions() {
try { try {
const response = await fetch('/discover_extensions'); const response = await fetch('/api/extensions/discover');
if (response.ok) { if (response.ok) {
const extensions = await response.json(); const extensions = await response.json();
@ -631,7 +631,7 @@ async function onUpdateClick() {
/** /**
* Handles the click event for the delete button of an extension. * 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. * If the extension is deleted, it displays a success message.
* Creates a popup for the user to confirm before delete. * 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'); const confirmation = await callPopup(`Are you sure you want to delete ${extensionName}?`, 'delete_extension');
if (confirmation) { if (confirmation) {
try { try {
const response = await fetch('/delete_extension', { const response = await fetch('/api/extensions/delete', {
method: 'POST', method: 'POST',
headers: getRequestHeaders(), headers: getRequestHeaders(),
body: JSON.stringify({ extensionName }) body: JSON.stringify({ extensionName })
@ -668,7 +668,7 @@ async function onDeleteClick() {
*/ */
async function getExtensionVersion(extensionName) { async function getExtensionVersion(extensionName) {
try { try {
const response = await fetch('/get_extension_version', { const response = await fetch('/api/extensions/version', {
method: 'POST', method: 'POST',
headers: getRequestHeaders(), headers: getRequestHeaders(),
body: JSON.stringify({ extensionName }) body: JSON.stringify({ extensionName })

425
server.js
View File

@ -15,7 +15,6 @@ const { TextDecoder } = require('util');
// cli/fs related library imports // cli/fs related library imports
const open = require('open'); const open = require('open');
const sanitize = require('sanitize-filename'); const sanitize = require('sanitize-filename');
const simpleGit = require('simple-git');
const writeFileAtomicSync = require('write-file-atomic').sync; const writeFileAtomicSync = require('write-file-atomic').sync;
const yargs = require('yargs/yargs'); const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers'); const { hideBin } = require('yargs/helpers');
@ -65,6 +64,7 @@ const contentManager = require('./src/content-manager');
const statsHelpers = require('./statsHelpers.js'); const statsHelpers = require('./statsHelpers.js');
const { writeSecret, readSecret, readSecretState, migrateSecrets, SECRET_KEYS, getAllSecrets } = require('./src/secrets'); const { writeSecret, readSecret, readSecretState, migrateSecrets, SECRET_KEYS, getAllSecrets } = require('./src/secrets');
const { delay, getVersion, getImageBuffers } = require('./src/util'); 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. // 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 // https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
@ -321,32 +321,7 @@ const AVATAR_WIDTH = 400;
const AVATAR_HEIGHT = 600; const AVATAR_HEIGHT = 600;
const jsonParser = express.json({ limit: '100mb' }); const jsonParser = express.json({ limit: '100mb' });
const urlencodedParser = express.urlencoded({ extended: true, limit: '100mb' }); const urlencodedParser = express.urlencoded({ extended: true, limit: '100mb' });
const directories = { const { directories } = require('./src/constants');
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',
};
// CSRF Protection // // CSRF Protection //
if (cliArguments.disableCsrf === false) { if (cliArguments.disableCsrf === false) {
@ -2728,36 +2703,6 @@ app.post('/deletegroup', jsonParser, async (request, response) => {
return response.send({ ok: true }); 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 * Gets the path to the sprites folder for the provided character name
* @param {string} name - The name of the character * @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() { function cleanUploads() {
try { try {
if (fs.existsSync(UPLOADS_PATH)) { 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 */ /* OpenAI */
app.post("/getstatus_openai", jsonParser, async function (request, response_getstatus_openai) { app.post("/getstatus_openai", jsonParser, async function (request, response_getstatus_openai) {
if (!request.body) return response_getstatus_openai.sendStatus(400); 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<Object>} - 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. * 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 // NovelAI generation
require('./src/novelai').registerEndpoints(app, jsonParser); require('./src/novelai').registerEndpoints(app, jsonParser);
// Third-party extensions
require('./src/extensions').registerEndpoints(app, jsonParser);
// Stable Diffusion generation // Stable Diffusion generation
require('./src/stable-diffusion').registerEndpoints(app, jsonParser); require('./src/stable-diffusion').registerEndpoints(app, jsonParser);

30
src/constants.js Normal file
View File

@ -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,
}

254
src/extensions.js Normal file
View File

@ -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<Object>} - 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<Object>} - 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,
}

201
src/thumbnails.js Normal file
View File

@ -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<void>} 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,
}