| import fs from 'node:fs'; |
| import { promises as fsPromises } from 'node:fs'; |
| import path from 'node:path'; |
|
|
| import mime from 'mime-types'; |
| import express from 'express'; |
| import sanitize from 'sanitize-filename'; |
| import { Jimp, JimpMime } from '../jimp.js'; |
| import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
|
|
| import { getConfigValue, invalidateFirefoxCache } from '../util.js'; |
|
|
| const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean'); |
| const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number')))); |
| const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png'; |
|
|
| |
| |
| |
|
|
| |
| export const dimensions = { |
| 'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]), |
| 'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]), |
| 'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]), |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| function getThumbnailFolder(directories, type) { |
| let thumbnailFolder; |
|
|
| switch (type) { |
| case 'bg': |
| thumbnailFolder = directories.thumbnailsBg; |
| break; |
| case 'avatar': |
| thumbnailFolder = directories.thumbnailsAvatar; |
| break; |
| case 'persona': |
| thumbnailFolder = directories.thumbnailsPersona; |
| break; |
| } |
|
|
| return thumbnailFolder; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function getOriginalFolder(directories, type) { |
| let originalFolder; |
|
|
| switch (type) { |
| case 'bg': |
| originalFolder = directories.backgrounds; |
| break; |
| case 'avatar': |
| originalFolder = directories.characters; |
| break; |
| case 'persona': |
| originalFolder = directories.avatars; |
| break; |
| } |
|
|
| return originalFolder; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function invalidateThumbnail(directories, type, file) { |
| const folder = getThumbnailFolder(directories, type); |
| if (folder === undefined) throw new Error('Invalid thumbnail type'); |
|
|
| const pathToThumbnail = path.join(folder, sanitize(file)); |
|
|
| if (fs.existsSync(pathToThumbnail)) { |
| fs.unlinkSync(pathToThumbnail); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| let shouldRegenerate = false; |
|
|
| if (cachedFileExists && originalFileExists) { |
| const originalStat = fs.statSync(pathToOriginalFile); |
| const cachedStat = fs.statSync(pathToCachedFile); |
|
|
| if (originalStat.mtimeMs > cachedStat.ctimeMs) { |
| |
| shouldRegenerate = true; |
| } |
| } |
|
|
| if (cachedFileExists && !shouldRegenerate) { |
| return pathToCachedFile; |
| } |
|
|
| if (!originalFileExists) { |
| return null; |
| } |
|
|
| try { |
| let buffer; |
|
|
| try { |
| const size = dimensions[type]; |
| const image = await Jimp.read(pathToOriginalFile); |
| const width = !isNaN(size?.[0]) && size?.[0] > 0 ? size[0] : image.bitmap.width; |
| const height = !isNaN(size?.[1]) && size?.[1] > 0 ? size[1] : image.bitmap.height; |
| image.cover({ w: width, h: height }); |
| buffer = pngFormat |
| ? await image.getBuffer(JimpMime.png) |
| : await image.getBuffer(JimpMime.jpeg, { quality: quality, jpegColorSpace: 'ycbcr' }); |
| } |
| catch (inner) { |
| console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`, inner); |
| buffer = fs.readFileSync(pathToOriginalFile); |
| } |
|
|
| writeFileAtomicSync(pathToCachedFile, buffer); |
| } |
| catch (outer) { |
| return null; |
| } |
|
|
| return pathToCachedFile; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function ensureThumbnailCache(directoriesList) { |
| for (const directories of directoriesList) { |
| const cacheFiles = fs.readdirSync(directories.thumbnailsBg); |
|
|
| |
| if (cacheFiles.length) { |
| continue; |
| } |
|
|
| console.info('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.info(`Done! Generated: ${bgFiles.length} preview images`); |
| } |
| } |
|
|
| export const router = express.Router(); |
|
|
| |
| router.get('/', 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' || type === 'persona')) { |
| return response.sendStatus(400); |
| } |
|
|
| if (sanitize(file) !== file) { |
| console.error('Malicious filename prevented'); |
| return response.sendStatus(403); |
| } |
|
|
| if (!thumbnailsEnabled) { |
| 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); |
|
|
| invalidateFirefoxCache(pathToOriginalFile, request, response); |
|
|
| 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); |
|
|
| invalidateFirefoxCache(file, request, response); |
|
|
| return response.send(cachedFile); |
| } catch (error) { |
| console.error('Failed getting thumbnail', error); |
| return response.sendStatus(500); |
| } |
| }); |
|
|