const fs = require('fs'); const fsPromises = require('fs').promises; const path = require('path'); const mime = require('mime-types'); const express = require('express'); const sanitize = require('sanitize-filename'); const jimp = require('jimp'); const writeFileAtomicSync = require('write-file-atomic').sync; const { getAllUserHandles, getUserDirectories } = require('../users'); const { getConfigValue } = require('../util'); const { jsonParser } = require('../express-common'); const thumbnailsDisabled = getConfigValue('disableThumbnails', false); const quality = getConfigValue('thumbnailsQuality', 95); const pngFormat = getConfigValue('avatarThumbnailsPng', false); /** * Gets a path to thumbnail folder based on the type. * @param {import('../users').UserDirectoryList} directories User directories * @param {'bg' | 'avatar'} type Thumbnail type * @returns {string} Path to the thumbnails folder */ function getThumbnailFolder(directories, 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 {import('../users').UserDirectoryList} directories User directories * @param {'bg' | 'avatar'} type Thumbnail type * @returns {string} Path to the original images folder */ function getOriginalFolder(directories, 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 {import('../users').UserDirectoryList} directories User directories * @param {'bg' | 'avatar'} type Type of the thumbnail * @param {string} file Name of the file */ function invalidateThumbnail(directories, type, file) { const folder = getThumbnailFolder(directories, 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 {import('../users').UserDirectoryList} directories User directories * @param {'bg' | 'avatar'} type Type of the thumbnail * @param {string} file Name of the file * @returns */ async function generateThumbnail(directories, type, file) { let thumbnailFolder = getThumbnailFolder(directories, type); let originalFolder = getOriginalFolder(directories, 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); const imgType = type == 'avatar' && pngFormat ? 'image/png' : 'image/jpeg'; buffer = await image.cover(mySize[0], mySize[1]).quality(quality).getBufferAsync(imgType); } 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 userHandles = await getAllUserHandles(); for (const handle of userHandles) { const directories = getUserDirectories(handle); 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(directories, 'bg', file)); } await Promise.all(tasks); console.log(`Done! Generated: ${bgFiles.length} preview images`); } } const router = express.Router(); // Important: This route must be mounted as '/thumbnail'. It is used in the client code and saved to chat files. router.get('/', jsonParser, async function (request, response) { try{ 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 (thumbnailsDisabled) { const folder = getOriginalFolder(request.user.directories, type); if (folder === undefined) { return response.sendStatus(400); } const pathToOriginalFile = path.join(folder, file); if (!fs.existsSync(pathToOriginalFile)) { return response.sendStatus(404); } const contentType = mime.lookup(pathToOriginalFile) || 'image/png'; const originalFile = await fsPromises.readFile(pathToOriginalFile); response.setHeader('Content-Type', contentType); return response.send(originalFile); } const pathToCachedFile = await generateThumbnail(request.user.directories, type, file); if (!pathToCachedFile) { return response.sendStatus(404); } if (!fs.existsSync(pathToCachedFile)) { return response.sendStatus(404); } const contentType = mime.lookup(pathToCachedFile) || 'image/jpeg'; const cachedFile = await fsPromises.readFile(pathToCachedFile); response.setHeader('Content-Type', contentType); return response.send(cachedFile); } catch (error) { console.error('Failed getting thumbnail', error); return response.sendStatus(500); } }); module.exports = { invalidateThumbnail, ensureThumbnailCache, router, };