|
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 } 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'; |
|
|
|
|
|
const dimensions = { |
|
'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]), |
|
'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]), |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getThumbnailFolder(directories, type) { |
|
let thumbnailFolder; |
|
|
|
switch (type) { |
|
case 'bg': |
|
thumbnailFolder = directories.thumbnailsBg; |
|
break; |
|
case 'avatar': |
|
thumbnailFolder = directories.thumbnailsAvatar; |
|
break; |
|
} |
|
|
|
return thumbnailFolder; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getOriginalFolder(directories, type) { |
|
let originalFolder; |
|
|
|
switch (type) { |
|
case 'bg': |
|
originalFolder = directories.backgrounds; |
|
break; |
|
case 'avatar': |
|
originalFolder = directories.characters; |
|
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, 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')) { |
|
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); |
|
} |
|
}); |
|
|