stail00016's picture
Upload 888 files
4342d5f verified
raw
history blame contribute delete
7.98 kB
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 from 'jimp';
import { sync as writeFileAtomicSync } from 'write-file-atomic';
import { getAllUserHandles, getUserDirectories } from '../users.js';
import { getConfigValue } 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';
/** @type {Record<string, number[]>} */
const dimensions = {
'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
};
/**
* Gets a path to thumbnail folder based on the type.
* @param {import('../users.js').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.js').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.js').UserDirectoryList} directories User directories
* @param {'bg' | 'avatar'} type Type of the thumbnail
* @param {string} file Name of the file
*/
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, file);
if (fs.existsSync(pathToThumbnail)) {
fs.rmSync(pathToThumbnail);
}
}
/**
* Generates a thumbnail for the given file.
* @param {import('../users.js').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.warn('Original file changed. Regenerating thumbnail...');
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 imgType = type == 'avatar' && pngFormat ? 'image/png' : 'image/jpeg';
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;
buffer = await image.cover(width, height).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<void>} Promise that resolves when the cache is validated
*/
export 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.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();
// Important: This route must be mounted as '/thumbnail'. It is used in the client code and saved to chat files.
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')) {
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);
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);
}
});