mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
237 lines
7.9 KiB
JavaScript
237 lines
7.9 KiB
JavaScript
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';
|
|
import { jsonParser } from '../express-common.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('/', 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 (!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);
|
|
}
|
|
});
|